From b9f681b9d90cc8b08522f1296d47823680c7b214 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 Nov 2024 13:57:07 +0100 Subject: [PATCH 1/7] wip: save content on edusharing --- src/backend/editor-route-handlers.ts | 106 +++++++++++++++++++++++++++ src/frontend/App.tsx | 21 +++--- 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/src/backend/editor-route-handlers.ts b/src/backend/editor-route-handlers.ts index 6b8ef90..2b1a4e4 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,57 @@ 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 + const platform = await Provider.getPlatform(idToken.iss) + 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 }) + + const url = new URL(getContentApiUrl) + url.searchParams.append('jwt', message) + + const edusharingResponse = await fetch(url.href) + + const edusharingResponseJson = await edusharingResponse.json() + + const response = { + id: '1', + resource_link_id: '1', + custom_claim_id: '1', + content: edusharingResponseJson, + } + + 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 +134,51 @@ export async function editorPutEntity(req: Request, res: Response) { return res.send('Success') } + +async function edusharingPutEntity(req: Request, res: Response) { + const editorState = req.body.editorState + const idToken = res.locals.token as IdToken + const platform = await Provider.getPlatform(idToken.iss) + 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 }) + + 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([JSON.stringify(editorState)], { + 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..1cec381 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -80,16 +80,17 @@ 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) From 8cef1f39587b7c21bd27c876189797afd8bc772c Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 Nov 2024 14:29:42 +0100 Subject: [PATCH 2/7] wip --- src/backend/editor-route-handlers.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/backend/editor-route-handlers.ts b/src/backend/editor-route-handlers.ts index 2b1a4e4..90d47fa 100644 --- a/src/backend/editor-route-handlers.ts +++ b/src/backend/editor-route-handlers.ts @@ -59,7 +59,8 @@ export async function editorGetEntity(req: Request, res: Response) { async function edusharingGetEntity(req: Request, res: Response) { const idToken = res.locals.token as IdToken - const platform = await Provider.getPlatform(idToken.iss) + // @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() @@ -81,20 +82,23 @@ async function edusharingGetEntity(req: Request, res: Response) { ...(version != null ? { version } : {}), dataToken, } - const message = jwt.sign(payload, privateKey, { keyid: keyId }) + 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 edusharingResponseJson = await edusharingResponse.json() + const edusharingResponseText = await edusharingResponse.text() const response = { id: '1', resource_link_id: '1', custom_claim_id: '1', - content: edusharingResponseJson, + content: edusharingResponseText, } return res.json(response) @@ -138,7 +142,8 @@ export async function editorPutEntity(req: Request, res: Response) { async function edusharingPutEntity(req: Request, res: Response) { const editorState = req.body.editorState const idToken = res.locals.token as IdToken - const platform = await Provider.getPlatform(idToken.iss) + // @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() @@ -158,7 +163,10 @@ async function edusharingPutEntity(req: Request, res: Response) { user, dataToken, } - const message = jwt.sign(payload, privateKey, { keyid: keyId }) + const message = jwt.sign(payload, privateKey, { + keyid: keyId, + algorithm: 'RS256', + }) const url = new URL(postContentApiUrl) url.searchParams.append('jwt', message) From e9a618e0659f42a40ea84dfa429a782be217c906 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 Nov 2024 15:19:56 +0100 Subject: [PATCH 3/7] fix: handle empty content string --- src/frontend/App.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index 1cec381..e2f9533 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -92,11 +92,10 @@ function App() { // 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(() => { From c197957211b8bd923ac94c6aca04b27998c3dad5 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 Nov 2024 15:53:53 +0100 Subject: [PATCH 4/7] fix: parse edu-sharing content twice --- src/backend/editor-route-handlers.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/backend/editor-route-handlers.ts b/src/backend/editor-route-handlers.ts index 90d47fa..7a2bcc7 100644 --- a/src/backend/editor-route-handlers.ts +++ b/src/backend/editor-route-handlers.ts @@ -92,13 +92,16 @@ async function edusharingGetEntity(req: Request, res: Response) { const edusharingResponse = await fetch(url.href) - const edusharingResponseText = await edusharingResponse.text() + // For some reason this is double stringified even though we save it only stringified once + const doubleStringifiedDocumentState = await edusharingResponse.text() + + const stringifiedDocumentState = JSON.parse(doubleStringifiedDocumentState) const response = { id: '1', resource_link_id: '1', custom_claim_id: '1', - content: edusharingResponseText, + content: stringifiedDocumentState, } return res.json(response) From cbce14bdbbb6d5db00c62adbdc1dccfaf0fd2417 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 Nov 2024 16:07:26 +0100 Subject: [PATCH 5/7] fix: not double stringify edusharing content --- src/backend/editor-route-handlers.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/backend/editor-route-handlers.ts b/src/backend/editor-route-handlers.ts index 7a2bcc7..5bc0202 100644 --- a/src/backend/editor-route-handlers.ts +++ b/src/backend/editor-route-handlers.ts @@ -92,10 +92,7 @@ async function edusharingGetEntity(req: Request, res: Response) { const edusharingResponse = await fetch(url.href) - // For some reason this is double stringified even though we save it only stringified once - const doubleStringifiedDocumentState = await edusharingResponse.text() - - const stringifiedDocumentState = JSON.parse(doubleStringifiedDocumentState) + const stringifiedDocumentState = await edusharingResponse.text() const response = { id: '1', @@ -143,7 +140,7 @@ export async function editorPutEntity(req: Request, res: Response) { } async function edusharingPutEntity(req: Request, res: Response) { - const editorState = req.body.editorState + const editorStateString = req.body.editorState as string const idToken = res.locals.token as IdToken // @ts-expect-error @types/ltijs const platform = await Provider.getPlatform(idToken.iss, idToken.clientId) @@ -176,7 +173,7 @@ async function edusharingPutEntity(req: Request, res: Response) { url.searchParams.append('mimetype', 'application/json') url.searchParams.append('versionComment', 'Automatische Speicherung') - const blob = new Blob([JSON.stringify(editorState)], { + const blob = new Blob([editorStateString], { type: 'application/json', }) From e489821034f10f9c73f4f134b307a62e1e2a8758 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 Nov 2024 19:40:44 +0100 Subject: [PATCH 6/7] fix: autosave --- src/frontend/SerloEditorWrapper.tsx | 47 +++++++++++++++-------------- 1 file changed, 25 insertions(+), 22 deletions(-) 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} From ce161e293fef1581ad4451bdcfdfac49b65aa1a8 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 Nov 2024 19:44:46 +0100 Subject: [PATCH 7/7] fix: stringify editor state before sending it to edu-sharing --- src/backend/editor-route-handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/editor-route-handlers.ts b/src/backend/editor-route-handlers.ts index 5bc0202..874d12d 100644 --- a/src/backend/editor-route-handlers.ts +++ b/src/backend/editor-route-handlers.ts @@ -140,7 +140,7 @@ export async function editorPutEntity(req: Request, res: Response) { } async function edusharingPutEntity(req: Request, res: Response) { - const editorStateString = req.body.editorState as string + 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)