diff --git a/src/backend/editor-route-handlers.ts b/src/backend/editor-route-handlers.ts index 6b8ef90..874d12d 100644 --- a/src/backend/editor-route-handlers.ts +++ b/src/backend/editor-route-handlers.ts @@ -5,6 +5,7 @@ import path from 'path' import { AccessToken, Entity } from '.' import { getMariaDB } from './mariadb' import config from '../utils/config' +import { IdToken, Provider } from 'ltijs' const ltijsKey = config.LTIJS_KEY @@ -13,6 +14,13 @@ export async function editorApp(_: Request, res: Response) { } export async function editorGetEntity(req: Request, res: Response) { + const idToken = res.locals.token as IdToken + const isEdusharing = idToken.iss.includes('edu-sharing') + + if (isEdusharing) { + return await edusharingGetEntity(req, res) + } + const database = getMariaDB() const accessToken = req.query.accessToken @@ -49,7 +57,61 @@ export async function editorGetEntity(req: Request, res: Response) { res.json(entity) } +async function edusharingGetEntity(req: Request, res: Response) { + const idToken = res.locals.token as IdToken + // @ts-expect-error @types/ltijs + const platform = await Provider.getPlatform(idToken.iss, idToken.clientId) + if (!platform) throw new Error('Platform was null') + const privateKey = await platform.platformPrivateKey() + const keyId = await platform.platformKid() + if (!privateKey || !keyId) + throw new Error('Private key or key id could not be retrieved') + const { appId, nodeId, user, getContentApiUrl, version, dataToken } = res + .locals.context?.custom as { + appId: string + nodeId: string + user: string + getContentApiUrl: string + version?: string + dataToken: string + } + const payload = { + appId, + nodeId, + user, + ...(version != null ? { version } : {}), + dataToken, + } + const message = jwt.sign(payload, privateKey, { + keyid: keyId, + algorithm: 'RS256', + }) + + const url = new URL(getContentApiUrl) + url.searchParams.append('jwt', message) + + const edusharingResponse = await fetch(url.href) + + const stringifiedDocumentState = await edusharingResponse.text() + + const response = { + id: '1', + resource_link_id: '1', + custom_claim_id: '1', + content: stringifiedDocumentState, + } + + return res.json(response) +} + export async function editorPutEntity(req: Request, res: Response) { + const idToken = res.locals.token as IdToken + const isEdusharing = idToken.iss.includes('edu-sharing') + + if (isEdusharing) { + return await edusharingPutEntity(req, res) + } + const database = getMariaDB() const accessToken = req.body.accessToken @@ -76,3 +138,55 @@ export async function editorPutEntity(req: Request, res: Response) { return res.send('Success') } + +async function edusharingPutEntity(req: Request, res: Response) { + const editorStateString = JSON.stringify(req.body.editorState) + const idToken = res.locals.token as IdToken + // @ts-expect-error @types/ltijs + const platform = await Provider.getPlatform(idToken.iss, idToken.clientId) + if (!platform) throw new Error('Platform was null') + const privateKey = await platform.platformPrivateKey() + const keyId = await platform.platformKid() + if (!privateKey || !keyId) + throw new Error('Private key or key id could not be retrieved') + const { appId, nodeId, user, postContentApiUrl, dataToken } = res.locals + .context?.custom as { + appId: string + nodeId: string + user: string + postContentApiUrl: string + dataToken: string + } + const payload = { + appId, + nodeId, + user, + dataToken, + } + const message = jwt.sign(payload, privateKey, { + keyid: keyId, + algorithm: 'RS256', + }) + + const url = new URL(postContentApiUrl) + url.searchParams.append('jwt', message) + url.searchParams.append('mimetype', 'application/json') + url.searchParams.append('versionComment', 'Automatische Speicherung') + + const blob = new Blob([editorStateString], { + type: 'application/json', + }) + + const data = new FormData() + data.set('file', blob) + + // https://medium.com/deno-the-complete-reference/sending-form-data-using-fetch-in-node-js-8cedd0b2af85 + const response = await fetch(url.href, { + method: 'POST', + body: data, + }) + + if (!response.ok) return res.send('Response not ok') + + return res.send('Success') +} diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index d9fbaf7..e2f9533 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -80,22 +80,22 @@ function App() { return } - if (resourceLinkIdFromDb !== resourceLinkIdFromUrl) { - setAppState({ - type: 'error', - // In German because we expect the user to see it - message: - 'Auf itslearning wurde eine Kopie erstellt. Leider ist dies aus technischen Gründen noch nicht möglich. Du kannst allerdings einen neuen Serlo Editor Inhalt auf itslearning erstellen und die gewünschten Inhalte per "Plugin in die Zwischenablage kopieren" & Strg-V dorthin übernehmen.', - imageURL: copyPluginToClipboardImage, - }) - return - } + // @@@ + // if (resourceLinkIdFromDb !== resourceLinkIdFromUrl) { + // setAppState({ + // type: 'error', + // // In German because we expect the user to see it + // message: + // 'Auf itslearning wurde eine Kopie erstellt. Leider ist dies aus technischen Gründen noch nicht möglich. Du kannst allerdings einen neuen Serlo Editor Inhalt auf itslearning erstellen und die gewünschten Inhalte per "Plugin in die Zwischenablage kopieren" & Strg-V dorthin übernehmen.', + // imageURL: copyPluginToClipboardImage, + // }) + // return + // } - const content = JSON.parse(entity.content) // console.log('content: ', content) setAppState({ type: mode === 'write' ? 'editor' : 'static-renderer', - content, + content: entity.content ? JSON.parse(entity.content) : null, }) }) .catch(() => { diff --git a/src/frontend/SerloEditorWrapper.tsx b/src/frontend/SerloEditorWrapper.tsx index b61ded2..b5ec1fc 100644 --- a/src/frontend/SerloEditorWrapper.tsx +++ b/src/frontend/SerloEditorWrapper.tsx @@ -5,7 +5,7 @@ import { type SerloEditorProps, } from '@serlo/editor' import { jwtDecode } from 'jwt-decode' -import React, { useEffect, useRef, useState } from 'react' +import React, { useCallback, useRef } from 'react' interface SerloContentProps { initialState: SerloEditorProps['initialState'] @@ -33,19 +33,13 @@ export default function SerloEditorWrapper(props: SerloContentProps) { const testingSecret = urlParams.get('testingSecret') const accessToken = urlParams.get('accessToken') - const [editorState, setEditorState] = useState( - JSON.stringify(props.initialState) - ) - const [savePending, setSavePending] = useState(false) - - const editorStateRef = useRef(editorState) + const savePendingRef = useRef(false) - // Save content if there are unsaved changed - useEffect(() => { - if (!savePending) return + const saveTimeoutRef = useRef(undefined) - setTimeout(saveContent, 1000) - function saveContent() { + const save = useCallback( + (newState: unknown) => { + savePendingRef.current = false fetch('/entity', { method: 'PUT', headers: { @@ -54,18 +48,31 @@ export default function SerloEditorWrapper(props: SerloContentProps) { }, body: JSON.stringify({ accessToken, - editorState: editorStateRef.current, + editorState: newState, }), }).then((res) => { if (res.status === 200) { - setSavePending(false) + // TODO: Show user content was saved successfully } else { // TODO: Handle failure } }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [savePending]) + }, + [accessToken, ltik] + ) + + const handleOnChange = useCallback( + (newState: unknown) => { + // If save already scheduled, cancel it + if (savePendingRef.current) { + clearTimeout(saveTimeoutRef.current) + } + savePendingRef.current = true + // Save after three seconds + saveTimeoutRef.current = window.setTimeout(() => save(newState), 2000) + }, + [save] + ) const plugins = getPlugins(ltik) function getPlugins(ltik: string) { @@ -85,11 +92,7 @@ export default function SerloEditorWrapper(props: SerloContentProps) { return ( { - editorStateRef.current = JSON.stringify(newState) - setEditorState(editorStateRef.current) - setSavePending(true) - }} + onChange={handleOnChange} editorVariant="lti-tool" _testingSecret={testingSecret} plugins={plugins}