Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Save content on edu-sharing #181

Draft
wants to merge 8 commits into
base: development
Choose a base branch
from
114 changes: 114 additions & 0 deletions src/backend/editor-route-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { AccessToken, Entity } from '.'
import { getMariaDB } from './mariadb'
import config from '../utils/config'
import { IdToken, Provider } from 'ltijs'

const ltijsKey = config.LTIJS_KEY

Expand All @@ -13,6 +14,13 @@
}

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
Expand All @@ -24,7 +32,7 @@
try {
decodedAccessToken = jwt.verify(accessToken, ltijsKey) as AccessToken
} catch (error) {
console.error(error)

Check warning on line 35 in src/backend/editor-route-handlers.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected console statement
return res.json({ content: 'Invalid access token' })
}

Expand All @@ -44,12 +52,66 @@
[String(decodedAccessToken.entityId)]
)

console.log('entity: ', entity)

Check warning on line 55 in src/backend/editor-route-handlers.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected console statement

res.json(entity)
}

async function edusharingGetEntity(req: Request, res: Response) {

Check failure on line 60 in src/backend/editor-route-handlers.ts

View workflow job for this annotation

GitHub Actions / tsc

'req' is declared but its value is never read.
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
Expand All @@ -68,7 +130,7 @@
req.body.editorState,
decodedAccessToken.entityId,
])
console.log(

Check warning on line 133 in src/backend/editor-route-handlers.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected console statement
`Entity ${
decodedAccessToken.entityId
} modified in database. New state:\n${req.body.editorState}`
Expand All @@ -76,3 +138,55 @@

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')
}
24 changes: 12 additions & 12 deletions src/frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import SerloEditorWrapper from './SerloEditorWrapper'
import { jwtDecode } from 'jwt-decode'
import { type AccessToken, type Entity } from '../backend'
import copyPluginToClipboardImage from './assets/copy-plugin-to-clipboard.png'

Check failure on line 10 in src/frontend/App.tsx

View workflow job for this annotation

GitHub Actions / eslint

'copyPluginToClipboardImage' is defined but never used

Check failure on line 10 in src/frontend/App.tsx

View workflow job for this annotation

GitHub Actions / tsc

'copyPluginToClipboardImage' is declared but its value is never read.
import Error from './Error'

import '@serlo/editor/dist/style.css'
Expand Down Expand Up @@ -80,22 +80,22 @@
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(() => {
Expand Down
47 changes: 25 additions & 22 deletions src/frontend/SerloEditorWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -33,19 +33,13 @@ export default function SerloEditorWrapper(props: SerloContentProps) {
const testingSecret = urlParams.get('testingSecret')
const accessToken = urlParams.get('accessToken')

const [editorState, setEditorState] = useState<string>(
JSON.stringify(props.initialState)
)
const [savePending, setSavePending] = useState<boolean>(false)

const editorStateRef = useRef(editorState)
const savePendingRef = useRef<boolean>(false)

// Save content if there are unsaved changed
useEffect(() => {
if (!savePending) return
const saveTimeoutRef = useRef<number | undefined>(undefined)

setTimeout(saveContent, 1000)
function saveContent() {
const save = useCallback(
(newState: unknown) => {
savePendingRef.current = false
fetch('/entity', {
method: 'PUT',
headers: {
Expand All @@ -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) {
Expand All @@ -85,11 +92,7 @@ export default function SerloEditorWrapper(props: SerloContentProps) {
return (
<MemoSerloEditor
initialState={initialState}
onChange={(newState) => {
editorStateRef.current = JSON.stringify(newState)
setEditorState(editorStateRef.current)
setSavePending(true)
}}
onChange={handleOnChange}
editorVariant="lti-tool"
_testingSecret={testingSecret}
plugins={plugins}
Expand Down
Loading