Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0fd8102
chore: prototype for import/export
jabbadizzleCode Dec 29, 2025
301c564
chore: import elements with preview
jabbadizzleCode Jan 12, 2026
b035920
Merge branch 'v3' of github.com:uzh-bf/klicker-uzh into import-export…
jabbadizzleCode Jun 30, 2026
ec7796b
Merge branch 'v3' of github.com:uzh-bf/klicker-uzh into import-export…
jabbadizzleCode Jul 6, 2026
38f0a94
chore: use zip to import and export elements and answer collections
jabbadizzleCode Jul 6, 2026
6567859
feat: improve import/export w.r.t. security
jabbadizzleCode Jul 7, 2026
b9d2dd2
docs: add import/export feature review
jabbadizzleCode Jul 7, 2026
d7ba24f
chore: add fingerprinting for duplicate elements and answercollections
jabbadizzleCode Jul 8, 2026
220f6a6
Merge branch 'v3' of github.com:uzh-bf/klicker-uzh into import-export…
jabbadizzleCode Jul 8, 2026
594626f
fix(import-export): restore media content type handling
jabbadizzleCode Jul 9, 2026
801728f
Merge branch 'v3' of github.com:uzh-bf/klicker-uzh into import-export…
jabbadizzleCode Jul 9, 2026
8c53967
docs(import-export): define production hardening design
jabbadizzleCode Jul 10, 2026
cd5909f
feat(import-export): implement and harden element package workflows
jabbadizzleCode Jul 14, 2026
f1819bc
fix(import-export): stabilize GraphQL and Playwright CI
jabbadizzleCode Jul 14, 2026
0d9d074
fix(import-export): complete production hardening
jabbadizzleCode Jul 17, 2026
406787d
fix(import-export): finalize production rollout
jabbadizzleCode Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion apps/backend-docker/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { EnvelopArmor } from '@escape.tech/graphql-armor'
import { useCSRFPrevention } from '@graphql-yoga/plugin-csrf-prevention'
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'
// import { useResponseCache } from '@graphql-yoga/plugin-response-cache'
import { enhanceContext, schema } from '@klicker-uzh/graphql'
import {
decodeLocalImportExportPackageBlobName,
enhanceContext,
isLocalImportExportPackageStorageEnabled,
readLocalImportExportPackageBlob,
schema,
writeLocalImportExportPackageBlob,
} from '@klicker-uzh/graphql'
import { verifyJWT } from '@klicker-uzh/util'
import cookieParser from 'cookie-parser'
import cors from 'cors'
Expand Down Expand Up @@ -191,6 +198,40 @@ function prepareApp({
res.send('OK')
})

if (isLocalImportExportPackageStorageEnabled()) {
app.put(
'/api/import-export-packages/:blobName',
express.raw({ type: '*/*', limit: '12mb' }),
async (req, res) => {
try {
const blobName = decodeLocalImportExportPackageBlobName(
req.params.blobName
)
await writeLocalImportExportPackageBlob(blobName, req.body)
res.status(201).send('OK')
} catch (error) {
console.error('Local import/export package upload failed:', error)
res.status(400).send('Invalid package reference.')
}
}
)

app.get('/api/import-export-packages/:blobName', async (req, res) => {
try {
const blobName = decodeLocalImportExportPackageBlobName(
req.params.blobName
)
const buffer = await readLocalImportExportPackageBlob(blobName)
res.setHeader('Content-Type', 'application/zip')
res.setHeader('Cache-Control', 'no-store')
res.send(buffer)
} catch (error) {
console.error('Local import/export package download failed:', error)
res.status(404).send('Package not found.')
}
})
}

app.use('/api/graphql', yogaApp as any)

return { app, yogaApp }
Expand Down
181 changes: 122 additions & 59 deletions apps/frontend-manage/src/components/common/MediaLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,92 @@ import Loader from '@klicker-uzh/shared-components/src/Loader'
import { Button } from '@uzh-bf/design-system'
import { useTranslations } from 'next-intl'
import Image from 'next/image'
import { Suspense, useCallback, useState } from 'react'
import { ReactNode, Suspense, useCallback, useState } from 'react'
import Dropzone from 'react-dropzone'
import { twMerge } from 'tailwind-merge'

interface Props {
onImageClick: (href: string, name: string) => void
}

interface MediaUploadDropzoneProps {
accept: Record<string, string[]>
title: ReactNode
description?: ReactNode
activeDescription?: ReactNode
inputAriaLabel?: string
compact?: boolean
isUploading?: boolean
multiple?: boolean
onDropAccepted: (files: File[]) => void | Promise<void>
data?: {
cy?: string
}
className?: {
root?: string
title?: string
description?: string
}
}

export function MediaUploadDropzone({
accept,
title,
description,
activeDescription,
inputAriaLabel,
compact = false,
isUploading = false,
multiple = false,
onDropAccepted,
data,
className,
}: MediaUploadDropzoneProps) {
return (
<Dropzone
onDropAccepted={onDropAccepted}
multiple={multiple}
accept={accept}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps({
className: twMerge(
'flex-1 p-2 hover:cursor-pointer hover:bg-slate-100',
compact ? 'flex min-h-10 items-center' : 'flex min-h-32 flex-col',
className?.root
),
'data-cy': data?.cy,
})}
>
<div className={twMerge('font-bold', className?.title)}>
{compact && isDragActive && activeDescription
? activeDescription
: title}
</div>
{!compact && (
<div className={twMerge('mt-2', className?.description)}>
{isUploading ? (
<Loader />
) : isDragActive && activeDescription ? (
activeDescription
) : (
description
)}
</div>
)}
<input
type="file"
{...getInputProps({
'aria-label': inputAriaLabel,
})}
/>
</div>
)}
</Dropzone>
)
}

function SuspendedMediaFiles({ onImageClick }: Props) {
const t = useTranslations()

Expand Down Expand Up @@ -55,73 +134,57 @@ function MediaLibrary({ onImageClick }: Props) {
if (!file) return

setIsUploading(true)
try {
const { data } = await getFileUploadSAS({
variables: {
fileName: file.name,
contentType: file.type,
},
})
if (!data?.getFileUploadSas) return

const { data } = await getFileUploadSAS({
variables: {
fileName: file.name,
contentType: file.type,
},
})
if (!data?.getFileUploadSas) return

const blobServiceClient = new BlobServiceClient(
data.getFileUploadSas.uploadSasURL
)
const containerClient = blobServiceClient.getContainerClient(
data.getFileUploadSas.containerName
)
const blobClient = containerClient.getBlobClient(
data.getFileUploadSas.fileName
)
const blockBlobClient = blobClient.getBlockBlobClient()
const result = await blockBlobClient.uploadData(file, {
blockSize: 4 * 1024 * 1024, // 4MB block size
})
const blobServiceClient = new BlobServiceClient(
data.getFileUploadSas.uploadSasURL
)
const containerClient = blobServiceClient.getContainerClient(
data.getFileUploadSas.containerName
)
const blobClient = containerClient.getBlobClient(
data.getFileUploadSas.fileName
)
const blockBlobClient = blobClient.getBlockBlobClient()
await blockBlobClient.uploadData(file, {
blockSize: 4 * 1024 * 1024, // 4MB block size
})

client.refetchQueries({
include: ['GetUserMediaFiles'],
})
client.refetchQueries({
include: ['GetUserMediaFiles'],
})

onImageClick(data.getFileUploadSas.uploadHref, file.name)

setIsUploading(false)
onImageClick(data.getFileUploadSas.uploadHref, file.name)
} finally {
setIsUploading(false)
}
},
[client, getFileUploadSAS, onImageClick]
)

return (
<Dropzone
onDrop={handleFileFieldChange}
multiple={false}
accept={{
'application/image': ['.png', '.jpg', '.jpeg', '.gif'],
}}
>
{({ getRootProps, getInputProps }) => (
<>
<Suspense fallback={<Loader />}>
<SuspendedMediaFiles onImageClick={onImageClick} />
</Suspense>
<>
<Suspense fallback={<Loader />}>
<SuspendedMediaFiles onImageClick={onImageClick} />
</Suspense>

<div
className="flex-1 p-2 hover:cursor-pointer hover:bg-slate-100"
{...getRootProps()}
>
<div className="font-bold">
{t('manage.elements.uploadImageHeader')}
</div>
<div className="mt-2">
{isUploading ? (
<Loader />
) : (
<p>{t('manage.elements.uploadImageDescription')}</p>
)}
</div>
<input type="file" {...getInputProps()} />
</div>
</>
)}
</Dropzone>
<MediaUploadDropzone
accept={{
'application/image': ['.png', '.jpg', '.jpeg', '.gif'],
}}
title={t('manage.elements.uploadImageHeader')}
description={<p>{t('manage.elements.uploadImageDescription')}</p>}
isUploading={isUploading}
onDropAccepted={handleFileFieldChange}
/>
</>
)
}

Expand Down
Loading
Loading