diff --git a/ui-build/webpack/esmac/errorsPendingRemoval.json b/ui-build/webpack/esmac/errorsPendingRemoval.json index cd8ffcba36c..814c3da871b 100644 --- a/ui-build/webpack/esmac/errorsPendingRemoval.json +++ b/ui-build/webpack/esmac/errorsPendingRemoval.json @@ -125,12 +125,6 @@ "target": "ui/shared/datetime/changeTimezone.js", "request": "@canvas/datetime/changeTimezone" }, - { - "name": "SpecifierMismatchError", - "source": "ui/shared/lti/jquery/messages.js", - "target": "ui/shared/rce/plugins/canvas_mentions/constants.js", - "request": "../../rce/plugins/canvas_mentions/constants" - }, { "name": "SpecifierMismatchError", "source": "ui/shared/outcomes/react/hooks/useGroupCreate.js", diff --git a/ui/shared/lti/jquery/lti_message_handler.ts b/ui/shared/lti/jquery/lti_message_handler.ts new file mode 100644 index 00000000000..42fa028817f --- /dev/null +++ b/ui/shared/lti/jquery/lti_message_handler.ts @@ -0,0 +1,29 @@ +/* + * 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 . + */ + +import {ResponseMessages} from './response_messages' + +export interface LtiMessageHandler { + /** + * /** + * A handler for a single type of LTI postMessage message + * @param params includes the message + * @returns true if the handler has already sent a response + */ + (params: {message: T; event: MessageEvent; responseMessages: ResponseMessages}): boolean +} diff --git a/ui/shared/lti/jquery/messages.js b/ui/shared/lti/jquery/messages.js deleted file mode 100644 index 800937d38d0..00000000000 --- a/ui/shared/lti/jquery/messages.js +++ /dev/null @@ -1,131 +0,0 @@ -/* - * 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 . - */ - -/* eslint no-console: 0 */ - -import { - NAVIGATION_MESSAGE as MENTIONS_NAVIGATION_MESSAGE, - INPUT_CHANGE_MESSAGE as MENTIONS_INPUT_CHANGE_MESSAGE, - SELECTION_MESSAGE as MENTIONS_SELECTION_MESSAGE, -} from '../../rce/plugins/canvas_mentions/constants' -import buildResponseMessages from './response_messages' - -// page-global storage for data relevant to LTI postMessage events -const ltiState = {} - -const SUBJECT_ALLOW_LIST = [ - 'lti.enableScrollEvents', - 'lti.fetchWindowSize', - 'lti.frameResize', - 'lti.hideRightSideWrapper', - 'lti.removeUnloadMessage', - 'lti.resourceImported', - 'lti.screenReaderAlert', - 'lti.scrollToTop', - 'lti.setUnloadMessage', - 'lti.showAlert', - 'lti.showModuleNavigation', - 'org.imsglobal.lti.capabilities', - 'org.imsglobal.lti.get_data', - 'org.imsglobal.lti.put_data', - 'requestFullWindowLaunch', - 'toggleCourseNavigationMenu', -] - -// These are handled elsewhere so ignore them -const SUBJECT_IGNORE_LIST = [ - 'A2ExternalContentReady', - 'LtiDeepLinkingResponse', - 'externalContentReady', - 'externalContentCancel', - MENTIONS_NAVIGATION_MESSAGE, - MENTIONS_INPUT_CHANGE_MESSAGE, - MENTIONS_SELECTION_MESSAGE, - 'betterchat.is_mini_chat', -] - -async function ltiMessageHandler(e) { - if (e.data?.source?.includes('react-devtools') || e.data.isAngularDevTools) { - return false - } - - let message - try { - message = typeof e.data === 'string' ? JSON.parse(e.data) : e.data - } catch (err) { - // unparseable message may not be meant for our handlers - return false - } - - // look at messageType for backwards compatibility - const subject = message.subject || message.messageType - const responseMessages = buildResponseMessages({ - targetWindow: e.source, - origin: e.origin, - subject, - message_id: message.message_id, - toolOrigin: message.toolOrigin, - }) - - if ( - SUBJECT_IGNORE_LIST.includes(subject) || - subject === undefined || - responseMessages.isResponse(e) - ) { - // These messages are handled elsewhere - return false - } else if (!SUBJECT_ALLOW_LIST.includes(subject)) { - // Enforce subject allowlist -- unknown type - responseMessages.sendUnsupportedSubjectError() - return false - } - - try { - const handlerModule = await import( - /* webpackExclude: /__tests__/ */ - `./subjects/${subject}.js` - ) - const hasSentResponse = handlerModule.default({ - message, - event: e, - responseMessages, - }) - if (!hasSentResponse) { - responseMessages.sendSuccess() - } - return true - } catch (error) { - console.error(`Error loading or executing message handler for "${subject}": ${error}`) - responseMessages.sendGenericError(error.message) - return false - } -} - -let hasListener = false - -function monitorLtiMessages() { - const cb = e => { - if (e.data !== '') ltiMessageHandler(e) - } - if (!hasListener) { - window.addEventListener('message', cb) - hasListener = true - } -} - -export {ltiState, SUBJECT_ALLOW_LIST, SUBJECT_IGNORE_LIST, ltiMessageHandler, monitorLtiMessages} diff --git a/ui/shared/lti/jquery/messages.ts b/ui/shared/lti/jquery/messages.ts new file mode 100644 index 00000000000..dac07c71e92 --- /dev/null +++ b/ui/shared/lti/jquery/messages.ts @@ -0,0 +1,198 @@ +/* + * 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 . + */ + +/* eslint no-console: 0 */ + +import { + NAVIGATION_MESSAGE as MENTIONS_NAVIGATION_MESSAGE, + INPUT_CHANGE_MESSAGE as MENTIONS_INPUT_CHANGE_MESSAGE, + SELECTION_MESSAGE as MENTIONS_SELECTION_MESSAGE, +} from '@canvas/rce/plugins/canvas_mentions/constants' +import {LtiMessageHandler} from './lti_message_handler' +import buildResponseMessages from './response_messages' +import {getKey, hasKey} from './util' + +// page-global storage for data relevant to LTI postMessage events +const ltiState: { + tray?: {refreshOnClose?: boolean} + fullWindowProxy?: Window | null +} = {} + +const SUBJECT_ALLOW_LIST = [ + 'lti.enableScrollEvents', + 'lti.fetchWindowSize', + 'lti.frameResize', + 'lti.hideRightSideWrapper', + 'lti.removeUnloadMessage', + 'lti.resourceImported', + 'lti.screenReaderAlert', + 'lti.scrollToTop', + 'lti.setUnloadMessage', + 'lti.showAlert', + 'lti.showModuleNavigation', + 'org.imsglobal.lti.capabilities', + 'org.imsglobal.lti.get_data', + 'org.imsglobal.lti.put_data', + 'requestFullWindowLaunch', + 'toggleCourseNavigationMenu', +] as const + +type SubjectId = typeof SUBJECT_ALLOW_LIST[number] + +const isAllowedSubject = (subject: unknown): subject is SubjectId => + typeof subject === 'string' && (SUBJECT_ALLOW_LIST as ReadonlyArray).includes(subject) + +const isIgnoredSubject = (subject: unknown): subject is SubjectId => + typeof subject === 'string' && (SUBJECT_IGNORE_LIST as ReadonlyArray).includes(subject) + +// These are handled elsewhere so ignore them +const SUBJECT_IGNORE_LIST = [ + 'A2ExternalContentReady', + 'LtiDeepLinkingResponse', + 'externalContentReady', + 'externalContentCancel', + MENTIONS_NAVIGATION_MESSAGE, + MENTIONS_INPUT_CHANGE_MESSAGE, + MENTIONS_SELECTION_MESSAGE, + 'betterchat.is_mini_chat', +] as const + +const isObject = (u: unknown): u is object => { + return typeof u === 'object' +} + +/** + * Returns true if the data from a message event is associated with a dev tool + * @param data - The `data` attribute from a message event + */ +const isDevtoolMessageData = (data: unknown): boolean => { + return ( + isObject(data) && + ((hasKey('source', data) && + typeof data.source === 'string' && + data.source.includes('react-devtools')) || + (hasKey('isAngularDevTools', data) && !!data.isAngularDevTools)) + ) +} + +/** + * A mapping of lti message id to a function that "handles" the message + * The values are functions to preserve the asynchronous loading of + * code that was present in the previous style. It may not be necessary. + */ +const handlers: Record< + typeof SUBJECT_ALLOW_LIST[number], + () => Promise<{default: LtiMessageHandler}> +> = { + 'lti.enableScrollEvents': () => import(`./subjects/lti.enableScrollEvents`), + 'lti.fetchWindowSize': () => import(`./subjects/lti.fetchWindowSize`), + 'lti.frameResize': () => import(`./subjects/lti.frameResize`), + 'lti.hideRightSideWrapper': () => import(`./subjects/lti.hideRightSideWrapper`), + 'lti.removeUnloadMessage': () => import(`./subjects/lti.removeUnloadMessage`), + 'lti.resourceImported': () => import(`./subjects/lti.resourceImported`), + 'lti.screenReaderAlert': () => import(`./subjects/lti.screenReaderAlert`), + 'lti.scrollToTop': () => import(`./subjects/lti.scrollToTop`), + 'lti.setUnloadMessage': () => import(`./subjects/lti.setUnloadMessage`), + 'lti.showAlert': () => import(`./subjects/lti.showAlert`), + 'lti.showModuleNavigation': () => import(`./subjects/lti.showModuleNavigation`), + 'org.imsglobal.lti.capabilities': () => import(`./subjects/org.imsglobal.lti.capabilities`), + 'org.imsglobal.lti.get_data': () => import(`./subjects/org.imsglobal.lti.get_data`), + 'org.imsglobal.lti.put_data': () => import(`./subjects/org.imsglobal.lti.put_data`), + requestFullWindowLaunch: () => import(`./subjects/requestFullWindowLaunch`), + toggleCourseNavigationMenu: () => import(`./subjects/toggleCourseNavigationMenu`), +} + +/** + * Handles 'message' events for LTI-related messages from LTI tools + * @param e + * @returns + */ +async function ltiMessageHandler(e: MessageEvent) { + if (isDevtoolMessageData(e.data)) { + return false + } + + let message: unknown + try { + message = typeof e.data === 'string' ? JSON.parse(e.data) : e.data + } catch (err) { + // unparseable message may not be meant for our handlers + return false + } + + if (typeof message !== 'object' || message === null) { + // unparseable message may not be meant for our handlers + return false + } + + const targetWindow = e.source as Window + + // look at messageType for backwards compatibility + const subject = getKey('subject', message) || getKey('messageType', message) + const responseMessages = buildResponseMessages({ + targetWindow, + origin: e.origin, + subject, + message_id: getKey('message_id', message), + toolOrigin: getKey('toolOrigin', message), + }) + + if (subject === undefined || isIgnoredSubject(subject) || responseMessages.isResponse(e)) { + // These messages are handled elsewhere + return false + } else if (!isAllowedSubject(subject)) { + responseMessages.sendUnsupportedSubjectError() + return false + } else { + try { + const handlerModule = await handlers[subject]() + const hasSentResponse = handlerModule.default({ + message, + event: e, + responseMessages, + }) + if (!hasSentResponse) { + responseMessages.sendSuccess() + } + return true + } catch (error) { + console.error(`Error loading or executing message handler for "${subject}": ${error}`) + + const message = + isObject(error) && hasKey('message', error) && typeof error.message === 'string' + ? error.message + : undefined + responseMessages.sendGenericError(message) + return false + } + } +} + +let hasListener = false + +function monitorLtiMessages() { + const cb = (e: MessageEvent) => { + if (e.data !== '') ltiMessageHandler(e) + } + if (!hasListener) { + window.addEventListener('message', cb) + hasListener = true + } +} + +export {ltiState, SUBJECT_ALLOW_LIST, SUBJECT_IGNORE_LIST, ltiMessageHandler, monitorLtiMessages} diff --git a/ui/shared/lti/jquery/platform_storage.js b/ui/shared/lti/jquery/platform_storage.ts similarity index 75% rename from ui/shared/lti/jquery/platform_storage.js rename to ui/shared/lti/jquery/platform_storage.ts index cb067aa1f80..50f9257c1ce 100644 --- a/ui/shared/lti/jquery/platform_storage.js +++ b/ui/shared/lti/jquery/platform_storage.ts @@ -32,22 +32,22 @@ const getLimit = tool_id => { return limits[tool_id] } -const clearLimit = tool_id => { +const clearLimit = (tool_id: string) => { delete limits[tool_id] } -const addToLimit = (tool_id, key, value) => { +const addToLimit = (tool_id: string, key: string, value: string) => { createLimit(tool_id) const length = key.length + value.length if (limits[tool_id].keyCount >= STORAGE_KEY_LIMIT) { - const e = new Error('Reached key limit for tool') + const e: Error & {code?: string} = new Error('Reached key limit for tool') e.code = 'storage_exhaustion' throw e } if (limits[tool_id].charCount + length > STORAGE_CHAR_LIMIT) { - const e = new Error('Reached byte limit for tool') + const e: Error & {code?: string} = new Error('Reached byte limit for tool') e.code = 'storage_exhaustion' throw e } @@ -56,7 +56,7 @@ const addToLimit = (tool_id, key, value) => { limits[tool_id].charCount += length } -const removeFromLimit = (tool_id, key, value) => { +const removeFromLimit = (tool_id: string, key: string, value: string) => { limits[tool_id].keyCount-- limits[tool_id].charCount -= key.length + value.length @@ -68,16 +68,16 @@ const removeFromLimit = (tool_id, key, value) => { } } -const getKey = (tool_id, key) => `lti|platform_storage|${tool_id}|${key}` +const getKey = (tool_id: string, key: string) => `lti|platform_storage|${tool_id}|${key}` -const putData = (tool_id, key, value) => { +const putData = (tool_id: string, key: string, value: string) => { addToLimit(tool_id, key, value) window.localStorage.setItem(getKey(tool_id, key), value) } -const getData = (tool_id, key) => window.localStorage.getItem(getKey(tool_id, key)) +const getData = (tool_id: string, key: string) => window.localStorage.getItem(getKey(tool_id, key)) -const clearData = (tool_id, key) => { +const clearData = (tool_id: string, key: string) => { const value = getData(tool_id, key) if (value) { removeFromLimit(tool_id, key, value) diff --git a/ui/shared/lti/jquery/response_messages.js b/ui/shared/lti/jquery/response_messages.ts similarity index 68% rename from ui/shared/lti/jquery/response_messages.js rename to ui/shared/lti/jquery/response_messages.ts index a7e0d2ce053..ea5f8890777 100644 --- a/ui/shared/lti/jquery/response_messages.js +++ b/ui/shared/lti/jquery/response_messages.ts @@ -21,9 +21,36 @@ const UNSUPPORTED_SUBJECT_ERROR_CODE = 'unsupported_subject' const WRONG_ORIGIN_ERROR_CODE = 'wrong_origin' const BAD_REQUEST_ERROR_CODE = 'bad_request' -const buildResponseMessages = ({targetWindow, origin, subject, message_id, toolOrigin}) => { +export interface ResponseMessages { + sendResponse: (contents?: {}) => void + sendSuccess: () => void + sendError: (code: string, message?: string | undefined) => void + sendGenericError: (message?: string | undefined) => void + sendBadRequestError: (message: any) => void + sendWrongOriginError: () => void + sendUnsupportedSubjectError: () => void + isResponse: (message: any) => boolean +} + +const buildResponseMessages = ({ + targetWindow, + origin, + subject, + message_id, + toolOrigin, +}: { + targetWindow: Window | null + origin: string + subject: unknown + message_id: unknown + toolOrigin: unknown +}): ResponseMessages => { const sendResponse = (contents = {}) => { - const message = {subject: `${subject}.response`} + const message: { + subject: string + message_id?: unknown + toolOrigin?: unknown + } = {subject: `${subject}.response`} if (message_id) { message.message_id = message_id } @@ -42,15 +69,15 @@ const buildResponseMessages = ({targetWindow, origin, subject, message_id, toolO sendResponse({}) } - const sendError = (code, message) => { - const error = {code} + const sendError = (code: string, message?: string) => { + const error: {code: string; message?: string} = {code} if (message) { error.message = message } sendResponse({error}) } - const sendGenericError = message => { + const sendGenericError = (message?: string) => { sendError(GENERIC_ERROR_CODE, message) } diff --git a/ui/shared/lti/jquery/subjects/lti.enableScrollEvents.js b/ui/shared/lti/jquery/subjects/lti.enableScrollEvents.ts similarity index 87% rename from ui/shared/lti/jquery/subjects/lti.enableScrollEvents.js rename to ui/shared/lti/jquery/subjects/lti.enableScrollEvents.ts index 828f924354c..1c0fa45db95 100644 --- a/ui/shared/lti/jquery/subjects/lti.enableScrollEvents.js +++ b/ui/shared/lti/jquery/subjects/lti.enableScrollEvents.ts @@ -16,7 +16,9 @@ * with this program. If not, see . */ -export default function enableScrollEvents({responseMessages}) { +import {LtiMessageHandler} from '../lti_message_handler' + +const enableScrollEvents: LtiMessageHandler = ({responseMessages}) => { let timeout window.addEventListener( 'scroll', @@ -37,3 +39,5 @@ export default function enableScrollEvents({responseMessages}) { ) return true } + +export default enableScrollEvents diff --git a/ui/shared/lti/jquery/subjects/lti.fetchWindowSize.js b/ui/shared/lti/jquery/subjects/lti.fetchWindowSize.ts similarity index 85% rename from ui/shared/lti/jquery/subjects/lti.fetchWindowSize.js rename to ui/shared/lti/jquery/subjects/lti.fetchWindowSize.ts index 0c5bfab745d..762c79a21fe 100644 --- a/ui/shared/lti/jquery/subjects/lti.fetchWindowSize.js +++ b/ui/shared/lti/jquery/subjects/lti.fetchWindowSize.ts @@ -17,8 +17,9 @@ */ import $ from 'jquery' +import {LtiMessageHandler} from '../lti_message_handler' -export default function fetchWindowSize({responseMessages}) { +const fetchWindowSize: LtiMessageHandler = ({responseMessages}) => { responseMessages.sendResponse({ height: window.innerHeight, width: window.innerWidth, @@ -28,3 +29,5 @@ export default function fetchWindowSize({responseMessages}) { }) return true } + +export default fetchWindowSize diff --git a/ui/shared/lti/jquery/subjects/lti.frameResize.js b/ui/shared/lti/jquery/subjects/lti.frameResize.ts similarity index 75% rename from ui/shared/lti/jquery/subjects/lti.frameResize.js rename to ui/shared/lti/jquery/subjects/lti.frameResize.ts index 590bfa74842..a7db49ba3e6 100644 --- a/ui/shared/lti/jquery/subjects/lti.frameResize.js +++ b/ui/shared/lti/jquery/subjects/lti.frameResize.ts @@ -18,10 +18,14 @@ import ToolLaunchResizer from '../tool_launch_resizer' import {findDomForWindow} from '../util' +import {LtiMessageHandler} from '../lti_message_handler' -export default function frameResize({message, event}) { +const frameResize: LtiMessageHandler<{height: number | string; token: string}> = ({ + message, + event, +}) => { const toolResizer = new ToolLaunchResizer() - let height = message.height + let height: number | string = message.height as number | string if (height <= 0) height = 1 const container = toolResizer @@ -34,11 +38,12 @@ export default function frameResize({message, event}) { // Attempt to find an embedded iframe that matches the event source. const iframe = findDomForWindow(event.source) if (iframe) { - if (typeof height === 'number') { - height += 'px' - } - iframe.height = height - iframe.style.height = height + const strHeight = typeof height === 'number' ? `${height}px` : height + iframe.height = strHeight + iframe.style.height = strHeight } } + return false } + +export default frameResize diff --git a/ui/shared/lti/jquery/subjects/lti.hideRightSideWrapper.js b/ui/shared/lti/jquery/subjects/lti.hideRightSideWrapper.ts similarity index 82% rename from ui/shared/lti/jquery/subjects/lti.hideRightSideWrapper.js rename to ui/shared/lti/jquery/subjects/lti.hideRightSideWrapper.ts index 6ad979b0322..30274cf258e 100644 --- a/ui/shared/lti/jquery/subjects/lti.hideRightSideWrapper.js +++ b/ui/shared/lti/jquery/subjects/lti.hideRightSideWrapper.ts @@ -17,7 +17,11 @@ */ import $ from 'jquery' +import {LtiMessageHandler} from '../lti_message_handler' -export default function hideRightSideWrapper() { +const hideRightSideWrapper: LtiMessageHandler = () => { $('#right-side-wrapper').hide() + return false } + +export default hideRightSideWrapper diff --git a/ui/shared/lti/jquery/subjects/lti.removeUnloadMessage.js b/ui/shared/lti/jquery/subjects/lti.removeUnloadMessage.ts similarity index 84% rename from ui/shared/lti/jquery/subjects/lti.removeUnloadMessage.js rename to ui/shared/lti/jquery/subjects/lti.removeUnloadMessage.ts index 6a8e14eb956..fb333704efb 100644 --- a/ui/shared/lti/jquery/subjects/lti.removeUnloadMessage.js +++ b/ui/shared/lti/jquery/subjects/lti.removeUnloadMessage.ts @@ -17,7 +17,11 @@ */ import {removeUnloadMessage} from '../util' +import {LtiMessageHandler} from '../lti_message_handler' -export default function remove() { +const remove: LtiMessageHandler = () => { removeUnloadMessage() + return false } + +export default remove diff --git a/ui/shared/lti/jquery/subjects/lti.resourceImported.js b/ui/shared/lti/jquery/subjects/lti.resourceImported.ts similarity index 88% rename from ui/shared/lti/jquery/subjects/lti.resourceImported.js rename to ui/shared/lti/jquery/subjects/lti.resourceImported.ts index d73588a3099..626a068fef2 100644 --- a/ui/shared/lti/jquery/subjects/lti.resourceImported.js +++ b/ui/shared/lti/jquery/subjects/lti.resourceImported.ts @@ -16,13 +16,15 @@ * with this program. If not, see . */ +import {LtiMessageHandler} from '../lti_message_handler' import {ltiState} from '../messages' -const handler = () => { +const handler: LtiMessageHandler = () => { if (!ltiState.tray) { ltiState.tray = {} } ltiState.tray.refreshOnClose = true + return false } export default handler diff --git a/ui/shared/lti/jquery/subjects/lti.screenReaderAlert.js b/ui/shared/lti/jquery/subjects/lti.screenReaderAlert.ts similarity index 81% rename from ui/shared/lti/jquery/subjects/lti.screenReaderAlert.js rename to ui/shared/lti/jquery/subjects/lti.screenReaderAlert.ts index 0e78f753d6a..46af6e1e41b 100644 --- a/ui/shared/lti/jquery/subjects/lti.screenReaderAlert.js +++ b/ui/shared/lti/jquery/subjects/lti.screenReaderAlert.ts @@ -17,9 +17,15 @@ */ import $ from '@canvas/rails-flash-notifications' +import {LtiMessageHandler} from '../lti_message_handler' -export default function screenReaderAlert({message}) { +const screenReaderAlert: LtiMessageHandler<{ + body: string | unknown +}> = ({message}) => { $.screenReaderFlashMessageExclusive( typeof message.body === 'string' ? message.body : JSON.stringify(message.body) ) + return false } + +export default screenReaderAlert diff --git a/ui/shared/lti/jquery/subjects/lti.scrollToTop.js b/ui/shared/lti/jquery/subjects/lti.scrollToTop.ts similarity index 79% rename from ui/shared/lti/jquery/subjects/lti.scrollToTop.js rename to ui/shared/lti/jquery/subjects/lti.scrollToTop.ts index a49083c2c8f..e2668b0bf7a 100644 --- a/ui/shared/lti/jquery/subjects/lti.scrollToTop.js +++ b/ui/shared/lti/jquery/subjects/lti.scrollToTop.ts @@ -17,12 +17,16 @@ */ import $ from 'jquery' +import {LtiMessageHandler} from '../lti_message_handler' -export default function scrollToTop() { +const scrollToTop: LtiMessageHandler = () => { $('html,body').animate( { - scrollTop: $('.tool_content_wrapper').offset().top, + scrollTop: $('.tool_content_wrapper').offset()?.top, }, 'fast' ) + return false } + +export default scrollToTop diff --git a/ui/shared/lti/jquery/subjects/lti.setUnloadMessage.js b/ui/shared/lti/jquery/subjects/lti.setUnloadMessage.ts similarity index 83% rename from ui/shared/lti/jquery/subjects/lti.setUnloadMessage.js rename to ui/shared/lti/jquery/subjects/lti.setUnloadMessage.ts index 77cba40ccf8..84fd02026fd 100644 --- a/ui/shared/lti/jquery/subjects/lti.setUnloadMessage.js +++ b/ui/shared/lti/jquery/subjects/lti.setUnloadMessage.ts @@ -18,7 +18,11 @@ import htmlEscape from 'html-escape' import {setUnloadMessage} from '../util' +import {LtiMessageHandler} from '../lti_message_handler' -export default function set({message}) { +const set: LtiMessageHandler<{message: string}> = ({message}) => { setUnloadMessage(htmlEscape(message.message)) + return false } + +export default set diff --git a/ui/shared/lti/jquery/subjects/lti.showAlert.js b/ui/shared/lti/jquery/subjects/lti.showAlert.ts similarity index 88% rename from ui/shared/lti/jquery/subjects/lti.showAlert.js rename to ui/shared/lti/jquery/subjects/lti.showAlert.ts index e0f0aa084ca..06b7702b1b8 100644 --- a/ui/shared/lti/jquery/subjects/lti.showAlert.js +++ b/ui/shared/lti/jquery/subjects/lti.showAlert.ts @@ -18,10 +18,15 @@ import $ from '@canvas/rails-flash-notifications' import {useScope as useI18nScope} from '@canvas/i18n' +import {LtiMessageHandler} from '../lti_message_handler' const I18n = useI18nScope('ltiMessages') -export default function showAlert({message, responseMessages}) { +const showAlert: LtiMessageHandler<{ + body: unknown + title?: string + alertType?: string +}> = ({message, responseMessages}) => { if (!message.body) { responseMessages.sendBadRequestError("Missing required 'body' field") return true @@ -49,3 +54,5 @@ export default function showAlert({message, responseMessages}) { responseMessages.sendSuccess() return true } + +export default showAlert diff --git a/ui/shared/lti/jquery/subjects/lti.showModuleNavigation.js b/ui/shared/lti/jquery/subjects/lti.showModuleNavigation.ts similarity index 81% rename from ui/shared/lti/jquery/subjects/lti.showModuleNavigation.js rename to ui/shared/lti/jquery/subjects/lti.showModuleNavigation.ts index 2645cfff2ae..ee6312ee0fc 100644 --- a/ui/shared/lti/jquery/subjects/lti.showModuleNavigation.js +++ b/ui/shared/lti/jquery/subjects/lti.showModuleNavigation.ts @@ -17,9 +17,15 @@ */ import $ from 'jquery' +import {LtiMessageHandler} from '../lti_message_handler' -export default function showModuleNavigation({message}) { +const showModuleNavigation: LtiMessageHandler<{ + show?: boolean +}> = ({message}) => { if (message.show === true || message.show === false) { $('.module-sequence-footer').toggle(message.show) } + return false } + +export default showModuleNavigation diff --git a/ui/shared/lti/jquery/subjects/org.imsglobal.lti.capabilities.js b/ui/shared/lti/jquery/subjects/org.imsglobal.lti.capabilities.ts similarity index 88% rename from ui/shared/lti/jquery/subjects/org.imsglobal.lti.capabilities.js rename to ui/shared/lti/jquery/subjects/org.imsglobal.lti.capabilities.ts index 2cddf76fc6d..cc63be302e9 100644 --- a/ui/shared/lti/jquery/subjects/org.imsglobal.lti.capabilities.js +++ b/ui/shared/lti/jquery/subjects/org.imsglobal.lti.capabilities.ts @@ -16,9 +16,10 @@ * with this program. If not, see . */ +import {LtiMessageHandler} from '../lti_message_handler' import {SUBJECT_ALLOW_LIST} from '../messages' -export default ({responseMessages}) => { +const handler: LtiMessageHandler = ({responseMessages}) => { const useFrame = ENV?.FEATURES?.lti_platform_storage const imsSubjects = ['org.imsglobal.lti.get_data', 'org.imsglobal.lti.put_data'] const supported_messages = SUBJECT_ALLOW_LIST.map(subject => { @@ -34,3 +35,5 @@ export default ({responseMessages}) => { responseMessages.sendResponse({supported_messages}) return true } + +export default handler diff --git a/ui/shared/lti/jquery/subjects/org.imsglobal.lti.get_data.js b/ui/shared/lti/jquery/subjects/org.imsglobal.lti.get_data.ts similarity index 84% rename from ui/shared/lti/jquery/subjects/org.imsglobal.lti.get_data.js rename to ui/shared/lti/jquery/subjects/org.imsglobal.lti.get_data.ts index e30d1dcf126..4ae9924b538 100644 --- a/ui/shared/lti/jquery/subjects/org.imsglobal.lti.get_data.js +++ b/ui/shared/lti/jquery/subjects/org.imsglobal.lti.get_data.ts @@ -17,8 +17,12 @@ */ import {getData} from '../platform_storage' +import {LtiMessageHandler} from '../lti_message_handler' -export default function handler({message, responseMessages, event}) { +const handler: LtiMessageHandler<{ + key: string + message_id: string +}> = ({message, responseMessages, event}) => { const {key, message_id} = message if (!key) { @@ -35,3 +39,5 @@ export default function handler({message, responseMessages, event}) { responseMessages.sendResponse({key, value}) return true } + +export default handler diff --git a/ui/shared/lti/jquery/subjects/org.imsglobal.lti.put_data.js b/ui/shared/lti/jquery/subjects/org.imsglobal.lti.put_data.ts similarity index 71% rename from ui/shared/lti/jquery/subjects/org.imsglobal.lti.put_data.js rename to ui/shared/lti/jquery/subjects/org.imsglobal.lti.put_data.ts index 1dad340c85e..d92e4e54b42 100644 --- a/ui/shared/lti/jquery/subjects/org.imsglobal.lti.put_data.js +++ b/ui/shared/lti/jquery/subjects/org.imsglobal.lti.put_data.ts @@ -17,8 +17,14 @@ */ import {clearData, putData} from '../platform_storage' +import {LtiMessageHandler} from '../lti_message_handler' +import {getKey} from '../util' -export default function handler({message, responseMessages, event}) { +const handler: LtiMessageHandler<{ + key: string + value: string + message_id: string +}> = ({message, responseMessages, event}) => { const {key, value, message_id} = message if (!key) { @@ -35,8 +41,13 @@ export default function handler({message, responseMessages, event}) { try { putData(event.origin, key, value) responseMessages.sendResponse({key, value}) - } catch (e) { - responseMessages.sendError(e.code, e.message) + } catch (e: unknown) { + const code = getKey('code', e) + const message = getKey('message', e) + responseMessages.sendError( + typeof code === 'string' ? code : '', + typeof message === 'string' ? message : undefined + ) } } else { clearData(event.origin, key) @@ -44,3 +55,5 @@ export default function handler({message, responseMessages, event}) { } return true } + +export default handler diff --git a/ui/shared/lti/jquery/subjects/requestFullWindowLaunch.js b/ui/shared/lti/jquery/subjects/requestFullWindowLaunch.ts similarity index 72% rename from ui/shared/lti/jquery/subjects/requestFullWindowLaunch.js rename to ui/shared/lti/jquery/subjects/requestFullWindowLaunch.ts index 4e43d59de4a..93ecaecd7a0 100644 --- a/ui/shared/lti/jquery/subjects/requestFullWindowLaunch.js +++ b/ui/shared/lti/jquery/subjects/requestFullWindowLaunch.ts @@ -15,9 +15,33 @@ * You should have received a copy of the GNU Affero General Public License along * with this program. If not, see . */ +import {LtiMessageHandler} from '../lti_message_handler' import {ltiState} from '../messages' +import {hasKey} from '../util' -const parseData = data => { +const parseData = ( + data: + | { + url?: string + display?: string + launchType?: string + launchOptions?: { + width?: number + height?: number + } + placement?: string + } + | string +): { + url: string + display: string + launchType: string + launchOptions: { + width?: number + height?: number + } + placement?: string +} => { const defaults = { display: 'borderless', launchType: 'same_window', @@ -28,20 +52,24 @@ const parseData = data => { url: data, ...defaults, } - } else if (typeof data === 'object' && !(data instanceof Array)) { - if (!data.url) { - throw new Error('message must contain a `url` property') - } - return { - ...defaults, - ...data, + } else if (typeof data === 'object' && !(data instanceof Array) && data !== null) { + if (hasKey('url', data)) { + const url = data.url + if (typeof url === 'string') { + return { + ...defaults, + ...data, + url, + } + } } + throw new Error('message must contain a `url` property') } else { throw new Error('message contents must either be a string or an object') } } -const buildLaunchUrl = (messageUrl, placement, display) => { +const buildLaunchUrl = (messageUrl: string, placement: string | undefined, display: string) => { let context = ENV.context_asset_string.replace('_', 's/') if (!(context.startsWith('account') || context.startsWith('course'))) { context = 'accounts/' + ENV.DOMAIN_ROOT_ACCOUNT_ID @@ -57,18 +85,29 @@ const buildLaunchUrl = (messageUrl, placement, display) => { const assignmentParam = assignmentId ? `&assignment_id=${assignmentId}` : '' // xsslint safeString.property window.location - toolLaunchUrl.searchParams.append('platform_redirect_url', window.location) + toolLaunchUrl.searchParams.append('platform_redirect_url', window.location.toString()) toolLaunchUrl.searchParams.append('full_win_launch_requested', '1') const encodedToolLaunchUrl = encodeURIComponent(toolLaunchUrl.toString()) return `${baseUrl}&url=${encodedToolLaunchUrl}${clientIdParam}${placementParam}${assignmentParam}` } -const handler = ({message}) => { +const handler: LtiMessageHandler<{ + data: { + url: string + launchType: string + launchOptions: { + width?: number + height?: number + } + placement: string + display: string + } +}> = ({message}) => { const {url, launchType, launchOptions, placement, display} = parseData(message.data) const launchUrl = buildLaunchUrl(url, placement, display) - let proxy + let proxy: Window | null = null switch (launchType) { case 'popup': { const width = launchOptions.width || 800 @@ -95,6 +134,7 @@ const handler = ({message}) => { // keep a reference to close later ltiState.fullWindowProxy = proxy + return false } export default handler diff --git a/ui/shared/lti/jquery/subjects/toggleCourseNavigationMenu.js b/ui/shared/lti/jquery/subjects/toggleCourseNavigationMenu.ts similarity index 85% rename from ui/shared/lti/jquery/subjects/toggleCourseNavigationMenu.js rename to ui/shared/lti/jquery/subjects/toggleCourseNavigationMenu.ts index 0c179e70a88..b4844245b85 100644 --- a/ui/shared/lti/jquery/subjects/toggleCourseNavigationMenu.js +++ b/ui/shared/lti/jquery/subjects/toggleCourseNavigationMenu.ts @@ -17,7 +17,11 @@ */ import {toggleCourseNav} from '@canvas/courses/jquery/toggleCourseNav' +import {LtiMessageHandler} from '../lti_message_handler' -const handler = () => toggleCourseNav() +const handler: LtiMessageHandler = () => { + toggleCourseNav() + return false +} export default handler diff --git a/ui/shared/lti/jquery/tool_launch_resizer.js b/ui/shared/lti/jquery/tool_launch_resizer.ts similarity index 77% rename from ui/shared/lti/jquery/tool_launch_resizer.js rename to ui/shared/lti/jquery/tool_launch_resizer.ts index 63fa6fc4871..cbe2dc10876 100644 --- a/ui/shared/lti/jquery/tool_launch_resizer.js +++ b/ui/shared/lti/jquery/tool_launch_resizer.ts @@ -19,15 +19,17 @@ import $ from 'jquery' export default class ToolLaunchResizer { - constructor(minToolHeight) { + minToolHeight: number + + constructor(minToolHeight?: number) { this.minToolHeight = minToolHeight || 450 } - sanitizedWrapperId(wrapperId) { + sanitizedWrapperId(wrapperId?: string) { return wrapperId?.toString()?.replace(/[^a-zA-Z0-9_-]/g, '') } - tool_content_wrapper(wrapperId) { + tool_content_wrapper(wrapperId?: string) { let container = $(`div[data-tool-wrapper-id*='${this.sanitizedWrapperId(wrapperId)}']`) const tool_content_wrapper = $('.tool_content_wrapper') if (container.length <= 0 && tool_content_wrapper.length === 1) { @@ -36,7 +38,15 @@ export default class ToolLaunchResizer { return container } - resize_tool_content_wrapper(height, container, force_height = false) { + resize_tool_content_wrapper( + height: number | string, + + // disabling b/c eslint fails, saying 'MessageEventSource' is not defined, but it's + // defined in lib.dom.d.ts + // eslint-disable-next-line no-undef + container: JQuery, + force_height = false + ) { let setHeight = height if (typeof setHeight !== 'number') { setHeight = this.minToolHeight diff --git a/ui/shared/lti/jquery/util.js b/ui/shared/lti/jquery/util.js deleted file mode 100644 index 5d449ff5afe..00000000000 --- a/ui/shared/lti/jquery/util.js +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (C) 2021 - 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 . - */ - -let beforeUnloadHandler -function setUnloadMessage(msg) { - removeUnloadMessage() - - beforeUnloadHandler = function (e) { - return (e.returnValue = msg || '') - } - window.addEventListener('beforeunload', beforeUnloadHandler) -} - -function removeUnloadMessage() { - if (beforeUnloadHandler) { - window.removeEventListener('beforeunload', beforeUnloadHandler) - beforeUnloadHandler = null - } -} - -function findDomForWindow(sourceWindow) { - const iframes = document.getElementsByTagName('IFRAME') - for (let i = 0; i < iframes.length; i += 1) { - if (iframes[i].contentWindow === sourceWindow) { - return iframes[i] - } - } - return null -} - -export {setUnloadMessage, removeUnloadMessage, findDomForWindow} diff --git a/ui/shared/lti/jquery/util.ts b/ui/shared/lti/jquery/util.ts new file mode 100644 index 00000000000..dd6e9d648e2 --- /dev/null +++ b/ui/shared/lti/jquery/util.ts @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2021 - 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 . + */ + +let beforeUnloadHandler: null | ((e: BeforeUnloadEvent) => void) = null + +export function setUnloadMessage(msg: string) { + removeUnloadMessage() + + beforeUnloadHandler = function (e: BeforeUnloadEvent) { + return (e.returnValue = msg || '') + } + window.addEventListener('beforeunload', beforeUnloadHandler) +} + +export function removeUnloadMessage() { + if (beforeUnloadHandler) { + window.removeEventListener('beforeunload', beforeUnloadHandler) + beforeUnloadHandler = null + } +} + +// disabling b/c eslint fails, saying 'MessageEventSource' is not defined, but it's +// defined in lib.dom.d.ts +// eslint-disable-next-line no-undef +export function findDomForWindow(sourceWindow?: MessageEventSource | null) { + const iframes = Array.from(document.getElementsByTagName('iframe')) + const iframe = iframes.find(iframe => iframe.contentWindow === sourceWindow) + return iframe || null +} + +// https://stackoverflow.com/a/70029241 +// this can be removed and replace with `k in o` +// when we upgrade to TS 4.9+ +export function hasKey( + k: K, + o: T +): o is T & Record { + return k in o +} + +export function getKey(key: string, o: unknown): unknown { + return typeof o === 'object' && o !== null && hasKey(key, o) ? o[key] : undefined +}