feat(fullstack): Add file upload

This commit is contained in:
2026-01-25 17:57:04 +01:00
parent c0f3adac07
commit 954c6d00e1
28 changed files with 1606 additions and 42 deletions

View File

@@ -36,6 +36,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.apache.tika:tika-core:2.9.1'
runtimeOnly 'com.h2database:h2'
implementation 'org.postgresql:postgresql'
implementation 'org.springframework.boot:spring-boot-testcontainers'

View File

@@ -133,6 +133,27 @@ class ApplicationFormFormatService(
"SWITCH" -> {
if (element.options.any { it.value == "true" }) "Ja" else "Nein"
}
"FILE_UPLOAD" -> {
val files =
element.options.mapNotNull { option ->
val value = option.value
if (value.isBlank()) {
null
} else {
try {
val metadata = jacksonObjectMapper().readValue(value, Map::class.java)
metadata["filename"] as? String
} catch (_: Exception) {
null
}
}
}
if (files.isEmpty()) {
"Keine Dateien hochgeladen"
} else {
files.joinToString("\\\\") { LatexEscaper.escape(it) }
}
}
"TABLE" -> {
renderTableValue(element).first
}

View File

@@ -10,6 +10,7 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedExc
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub.file.FileService
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementMapper
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
import com.betriebsratkanzlei.legalconsenthub.user.UserService
@@ -22,6 +23,7 @@ import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
data class ApplicationFormPageWithCommentCounts(
@@ -39,7 +41,9 @@ class ApplicationFormService(
private val userService: UserService,
private val eventPublisher: ApplicationEventPublisher,
private val commentRepository: CommentRepository,
private val fileService: FileService,
) {
@Transactional(rollbackFor = [Exception::class])
fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
val savedApplicationForm: ApplicationForm
@@ -49,6 +53,15 @@ class ApplicationFormService(
throw ApplicationFormNotCreatedException(e)
}
// Associate files atomically if provided
val fileIds = applicationFormDto.fileIds
if (!fileIds.isNullOrEmpty()) {
fileService.associateTemporaryFilesTransactional(
fileIds,
savedApplicationForm,
)
}
val currentUser = userService.getCurrentUser()
versionService.createVersion(savedApplicationForm, currentUser)

View File

@@ -0,0 +1,8 @@
package com.betriebsratkanzlei.legalconsenthub.error
import java.util.UUID
class FileNotFoundException(
id: UUID,
message: String = "File not found: $id",
) : RuntimeException(message)

View File

@@ -0,0 +1,6 @@
package com.betriebsratkanzlei.legalconsenthub.error
class FileStorageException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message, cause)

View File

@@ -0,0 +1,5 @@
package com.betriebsratkanzlei.legalconsenthub.error
class FileTooLargeException(
message: String,
) : RuntimeException(message)

View File

@@ -0,0 +1,5 @@
package com.betriebsratkanzlei.legalconsenthub.error
class UnsupportedFileTypeException(
message: String,
) : RuntimeException(message)

View File

@@ -0,0 +1,125 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileTooLargeException
import com.betriebsratkanzlei.legalconsenthub.error.UnsupportedFileTypeException
import com.betriebsratkanzlei.legalconsenthub_api.api.FileApi
import com.betriebsratkanzlei.legalconsenthub_api.model.AssociateFilesWithApplicationFormRequest
import com.betriebsratkanzlei.legalconsenthub_api.model.UploadedFileDto
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import java.util.UUID
@RestController
class FileController(
private val fileService: FileService,
private val fileMapper: FileMapper,
) : FileApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun uploadFile(
file: MultipartFile,
formElementReference: String,
organizationId: String?,
applicationFormId: UUID?,
): ResponseEntity<UploadedFileDto> =
try {
val uploadedFile = fileService.uploadFile(file, applicationFormId, formElementReference, organizationId)
ResponseEntity
.status(HttpStatus.CREATED)
.body(fileMapper.toDto(uploadedFile))
} catch (e: FileTooLargeException) {
ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).build()
} catch (e: UnsupportedFileTypeException) {
ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getFileById(id: UUID): ResponseEntity<UploadedFileDto> =
try {
val uploadedFile = fileService.getFile(id)
ResponseEntity.ok(fileMapper.toDto(uploadedFile))
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun downloadFileContent(
id: UUID,
inline: Boolean,
): ResponseEntity<Resource> =
try {
val (uploadedFile, bytes) = fileService.downloadFile(id)
val resource = ByteArrayResource(bytes)
val disposition = if (inline) "inline" else "attachment"
ResponseEntity
.ok()
.contentType(MediaType.parseMediaType(uploadedFile.mimeType))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"$disposition; filename=\"${uploadedFile.originalFilename}\"",
).body(resource)
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun getFilesByApplicationForm(
applicationFormId: UUID,
formElementReference: String?,
): ResponseEntity<List<UploadedFileDto>> {
val files =
if (formElementReference != null) {
fileService.getFilesByElement(applicationFormId, formElementReference)
} else {
fileService.getFilesByApplicationForm(applicationFormId)
}
return ResponseEntity.ok(files.map { fileMapper.toDto(it) })
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun deleteFile(id: UUID): ResponseEntity<Unit> =
try {
fileService.deleteFile(id)
ResponseEntity.noContent().build()
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
}
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
)
override fun associateFilesWithApplicationForm(
applicationFormId: UUID,
associateFilesWithApplicationFormRequest: AssociateFilesWithApplicationFormRequest,
): ResponseEntity<Unit> =
try {
fileService.associateTemporaryFiles(
associateFilesWithApplicationFormRequest.fileIds,
applicationFormId,
)
ResponseEntity.noContent().build()
} catch (e: FileNotFoundException) {
ResponseEntity.notFound().build()
} catch (e: ApplicationFormNotFoundException) {
ResponseEntity.notFound().build()
}
}

View File

@@ -0,0 +1,25 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
import com.betriebsratkanzlei.legalconsenthub_api.model.UploadedFileDto
import org.springframework.stereotype.Component
import java.time.Instant
@Component
class FileMapper(
private val userMapper: UserMapper,
) {
fun toDto(uploadedFile: UploadedFile): UploadedFileDto =
UploadedFileDto(
id = uploadedFile.id ?: throw IllegalStateException("File ID must not be null"),
filename = uploadedFile.filename,
originalFilename = uploadedFile.originalFilename,
propertySize = uploadedFile.size,
mimeType = uploadedFile.mimeType,
organizationId = uploadedFile.organizationId,
applicationFormId = uploadedFile.applicationForm?.id,
formElementReference = uploadedFile.formElementReference,
uploadedAt = uploadedFile.uploadedAt ?: Instant.now(),
uploadedBy = uploadedFile.uploadedBy?.let { userMapper.toUserDto(it) },
)
}

View File

@@ -0,0 +1,23 @@
package com.betriebsratkanzlei.legalconsenthub.file
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.Instant
import java.util.UUID
@Repository
interface FileRepository : JpaRepository<UploadedFile, UUID> {
fun findByApplicationFormId(applicationFormId: UUID): List<UploadedFile>
fun findByApplicationFormIdAndFormElementReference(
applicationFormId: UUID,
formElementReference: String,
): List<UploadedFile>
@Query("SELECT f FROM UploadedFile f WHERE f.isTemporary = true AND f.uploadedAt < :cutoffDate")
fun findTemporaryFilesOlderThan(
@Param("cutoffDate") cutoffDate: Instant,
): List<UploadedFile>
}

View File

@@ -0,0 +1,376 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormRepository
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.FileStorageException
import com.betriebsratkanzlei.legalconsenthub.error.FileTooLargeException
import com.betriebsratkanzlei.legalconsenthub.error.UnsupportedFileTypeException
import com.betriebsratkanzlei.legalconsenthub.security.CustomJwtTokenPrincipal
import com.betriebsratkanzlei.legalconsenthub.user.UserRepository
import org.apache.tika.Tika
import org.slf4j.LoggerFactory
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.util.UUID
@Service
class FileService(
private val fileRepository: FileRepository,
private val fileStorage: FileStorage,
private val applicationFormRepository: ApplicationFormRepository,
private val userRepository: UserRepository,
) {
private val logger = LoggerFactory.getLogger(FileService::class.java)
private val tika = Tika()
companion object {
private const val MAX_FILE_SIZE = 10 * 1024 * 1024L // 10MB in bytes
private val ALLOWED_MIME_TYPES =
setOf(
"application/pdf",
"application/x-pdf", // PDF variant detected by some tools
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // DOCX
"application/msword", // DOC
"application/vnd.oasis.opendocument.text", // ODT
"image/jpeg",
"image/png",
"application/zip",
"application/x-zip-compressed",
)
}
fun uploadFile(
file: MultipartFile,
applicationFormId: UUID?,
formElementReference: String,
organizationId: String?,
): UploadedFile {
if (file.size > MAX_FILE_SIZE) {
throw FileTooLargeException(
"File size ${file.size} bytes exceeds maximum allowed size $MAX_FILE_SIZE bytes",
)
}
val detectedMimeType =
try {
tika.detect(file.inputStream)
} catch (e: Exception) {
logger.error("Failed to detect MIME type for file: ${file.originalFilename}", e)
throw UnsupportedFileTypeException("Failed to detect file type")
}
if (detectedMimeType !in ALLOWED_MIME_TYPES) {
logger.warn("Rejected file '${file.originalFilename}' with detected MIME type: $detectedMimeType")
throw UnsupportedFileTypeException("File type $detectedMimeType is not allowed")
}
val sanitizedFilename = sanitizeFilename(file.originalFilename ?: "unnamed")
val uniqueFilename = "${UUID.randomUUID()}_$sanitizedFilename"
// Get application form if ID provided (null for temporary uploads)
val applicationForm =
applicationFormId?.let {
applicationFormRepository
.findById(it)
.orElseThrow { ApplicationFormNotFoundException(it) }
}
val isTemporary = applicationFormId == null
val principal = SecurityContextHolder.getContext().authentication.principal as? CustomJwtTokenPrincipal
val currentUser = principal?.id?.let { userRepository.findById(it).orElse(null) }
val storageKey =
FileStorageKey(
organizationId = organizationId,
applicationFormId = applicationFormId,
formElementReference = formElementReference,
filename = uniqueFilename,
)
val storagePath =
try {
val fileBytes = file.bytes
fileStorage.store(storageKey, fileBytes)
} catch (e: Exception) {
logger.error("Failed to store file: ${file.originalFilename}", e)
throw FileStorageException("Failed to store file", e)
}
val uploadedFile =
UploadedFile(
filename = uniqueFilename,
originalFilename = sanitizedFilename,
size = file.size,
mimeType = detectedMimeType,
organizationId = organizationId,
applicationForm = applicationForm,
formElementReference = formElementReference,
storagePath = storagePath,
uploadedBy = currentUser,
isTemporary = isTemporary,
)
return try {
fileRepository.save(uploadedFile)
} catch (e: Exception) {
// Cleanup storage if database save fails
try {
fileStorage.delete(storageKey)
} catch (cleanupException: Exception) {
logger.error("Failed to cleanup file after database save failure", cleanupException)
}
logger.error("Failed to save file metadata to database", e)
throw FileStorageException("Failed to save file metadata", e)
}
}
fun getFile(id: UUID): UploadedFile =
fileRepository
.findById(id)
.orElseThrow { FileNotFoundException(id) }
fun downloadFile(id: UUID): Pair<UploadedFile, ByteArray> {
val uploadedFile = getFile(id)
val storageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = uploadedFile.applicationForm?.id,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
val bytes =
fileStorage.retrieve(storageKey)
?: throw FileNotFoundException(id, "File not found in storage")
return Pair(uploadedFile, bytes)
}
fun getFilesByApplicationForm(applicationFormId: UUID): List<UploadedFile> =
fileRepository.findByApplicationFormId(applicationFormId)
fun getFilesByElement(
applicationFormId: UUID,
formElementReference: String,
): List<UploadedFile> =
fileRepository.findByApplicationFormIdAndFormElementReference(
applicationFormId,
formElementReference,
)
fun deleteFile(id: UUID) {
val uploadedFile = getFile(id)
val storageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = uploadedFile.applicationForm?.id,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
try {
fileStorage.delete(storageKey)
} catch (e: Exception) {
logger.error("Failed to delete file from storage: $id", e)
// Continue with database deletion even if storage deletion fails
}
try {
fileRepository.delete(uploadedFile)
} catch (e: Exception) {
logger.error("Failed to delete file from database: $id", e)
throw FileStorageException("Failed to delete file", e)
}
}
/**
* Associates temporary files with already existing application form.
*/
fun associateTemporaryFiles(
fileIds: List<UUID>,
applicationFormId: UUID,
) {
val applicationForm =
applicationFormRepository
.findById(applicationFormId)
.orElseThrow { ApplicationFormNotFoundException(applicationFormId) }
fileIds.forEach { fileId ->
val uploadedFile = getFile(fileId)
if (!uploadedFile.isTemporary) {
return@forEach
}
// Move file from temporary storage to application form storage
val oldStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = null,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
val newStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = applicationFormId,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
// Retrieve file from temporary location
val bytes =
fileStorage.retrieve(oldStorageKey)
?: throw FileNotFoundException(fileId, "Temporary file not found in storage")
// Store in new location
val newStoragePath = fileStorage.store(newStorageKey, bytes)
// Delete from old location
fileStorage.delete(oldStorageKey)
// Update database record
uploadedFile.applicationForm = applicationForm
uploadedFile.isTemporary = false
uploadedFile.storagePath = newStoragePath
fileRepository.save(uploadedFile)
}
}
/**
* Associates temporary files with a newly created application form atomically.
* Uses a copy-first pattern with compensating transactions for filesystem safety:
* 1. Copy files to new locations (reversible)
* 2. Update database records (within transaction)
* 3. Delete original files (cleanup)
*
* If any step fails, compensating transactions clean up copied files
* and the database transaction rolls back.
*
*/
@Transactional(rollbackFor = [Exception::class])
fun associateTemporaryFilesTransactional(
fileIds: List<UUID>,
applicationForm: ApplicationForm,
) {
if (fileIds.isEmpty()) {
return
}
// Track moved files for compensating transaction
data class MovedFile(
val oldKey: FileStorageKey,
val newKey: FileStorageKey,
val uploadedFile: UploadedFile,
val newStoragePath: String,
)
val movedFiles = mutableListOf<MovedFile>()
try {
fileIds.forEach { fileId ->
val uploadedFile =
fileRepository
.findById(fileId)
.orElseThrow { FileNotFoundException(fileId) }
if (!uploadedFile.isTemporary) {
logger.debug("Skipping non-temporary file: {}", fileId)
return@forEach
}
val oldStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = null,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
val newStorageKey =
FileStorageKey(
organizationId = uploadedFile.organizationId,
applicationFormId = applicationForm.id!!,
formElementReference = uploadedFile.formElementReference,
filename = uploadedFile.filename,
)
// Step 1: Copy file to new location (don't delete yet)
val bytes =
fileStorage.retrieve(oldStorageKey)
?: throw FileNotFoundException(fileId, "Temporary file not found in storage")
val newStoragePath = fileStorage.store(newStorageKey, bytes)
movedFiles.add(MovedFile(oldStorageKey, newStorageKey, uploadedFile, newStoragePath))
// Step 2: Update database record (within transaction)
uploadedFile.applicationForm = applicationForm
uploadedFile.isTemporary = false
uploadedFile.storagePath = newStoragePath
fileRepository.save(uploadedFile)
}
// Step 3: All succeeded - delete old files (cleanup)
movedFiles.forEach { movedFile ->
try {
fileStorage.delete(movedFile.oldKey)
} catch (e: Exception) {
// Log but don't fail - cleanup scheduler will handle orphaned files
logger.warn("Failed to delete old temporary file: ${movedFile.oldKey.filename}", e)
}
}
logger.info("Successfully associated ${movedFiles.size} files with application form $applicationForm.id")
} catch (e: Exception) {
// Compensating transaction: cleanup copied files
movedFiles.forEach { movedFile ->
try {
fileStorage.delete(movedFile.newKey)
logger.debug("Cleaned up copied file: ${movedFile.newKey.filename}")
} catch (cleanupException: Exception) {
logger.error(
"Failed to cleanup copied file during rollback: ${movedFile.newKey.filename}",
cleanupException,
)
}
}
logger.error("Failed to associate temporary files with application form $applicationForm.id", e)
throw e
}
}
fun deleteTemporaryFilesOlderThan(days: Long) {
val cutoffDate =
java.time.Instant
.now()
.minus(days, java.time.temporal.ChronoUnit.DAYS)
val temporaryFiles = fileRepository.findTemporaryFilesOlderThan(cutoffDate)
logger.info("Found ${temporaryFiles.size} temporary files older than $days days")
temporaryFiles.forEach { file ->
try {
deleteFile(file.id!!)
logger.info("Deleted temporary file: ${file.id}")
} catch (e: Exception) {
logger.error("Failed to delete temporary file: ${file.id}", e)
}
}
}
private fun sanitizeFilename(filename: String): String {
// Remove path separators and limit length
return filename
.replace(Regex("[/\\\\]"), "_")
.replace(Regex("\\.\\./"), "_")
.take(255)
}
}

View File

@@ -0,0 +1,29 @@
package com.betriebsratkanzlei.legalconsenthub.file
import java.util.UUID
data class FileStorageKey(
val organizationId: String?,
val applicationFormId: UUID?,
val formElementReference: String,
val filename: String,
) {
fun toPathParts(): List<String> {
val orgId = organizationId ?: "global"
val formId = applicationFormId?.toString() ?: "temporary"
return listOf(orgId, formId, formElementReference, filename)
}
}
interface FileStorage {
fun store(
key: FileStorageKey,
bytes: ByteArray,
): String
fun retrieve(key: FileStorageKey): ByteArray?
fun delete(key: FileStorageKey): Boolean
fun exists(key: FileStorageKey): Boolean
}

View File

@@ -0,0 +1,33 @@
package com.betriebsratkanzlei.legalconsenthub.file
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.context.properties.NestedConfigurationProperty
import org.springframework.context.annotation.Configuration
@Configuration
@EnableConfigurationProperties(FileStorageProperties::class)
class FileStorageConfiguration
@ConfigurationProperties(prefix = "legalconsenthub.file.storage")
data class FileStorageProperties(
@NestedConfigurationProperty
val filesystem: FileSystemProperties = FileSystemProperties(),
) {
data class FileSystemProperties(
/**
* Base directory for uploaded files. In development this defaults to a folder next to the backend code.
*
* Configure either via application.yaml:
* legalconsenthub:
* file:
* storage:
* filesystem:
* base-dir: /var/lib/legalconsenthub/files
*
* or via environment variable:
* LEGALCONSENTHUB_FILE_STORAGE_FILESYSTEM_BASE_DIR=/var/lib/legalconsenthub/files
*/
val baseDir: String = "./data/files",
)
}

View File

@@ -0,0 +1,78 @@
package com.betriebsratkanzlei.legalconsenthub.file
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import kotlin.io.path.exists
import kotlin.io.path.inputStream
@Component
class FileSystemFileStorage(
private val properties: FileStorageProperties,
) : FileStorage {
private val logger = LoggerFactory.getLogger(FileSystemFileStorage::class.java)
override fun store(
key: FileStorageKey,
bytes: ByteArray,
): String {
val targetPath = resolvePath(key)
Files.createDirectories(targetPath.parent)
val tmpFile = Files.createTempFile(targetPath.parent, targetPath.fileName.toString(), ".tmp")
try {
Files.write(tmpFile, bytes)
try {
Files.move(
tmpFile,
targetPath,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (e: Exception) {
logger.debug("Atomic move failed, falling back to non-atomic move: ${e.message}")
Files.move(
tmpFile,
targetPath,
StandardCopyOption.REPLACE_EXISTING,
)
}
} finally {
try {
Files.deleteIfExists(tmpFile)
} catch (_: Exception) {
// ignore
}
}
return targetPath.toString()
}
override fun retrieve(key: FileStorageKey): ByteArray? {
val path = resolvePath(key)
if (!path.exists()) return null
return path.inputStream().use { it.readBytes() }
}
override fun delete(key: FileStorageKey): Boolean {
val path = resolvePath(key)
return try {
Files.deleteIfExists(path)
} catch (e: Exception) {
logger.error("Failed to delete file at $path", e)
false
}
}
override fun exists(key: FileStorageKey): Boolean {
val path = resolvePath(key)
return path.exists()
}
private fun resolvePath(key: FileStorageKey): Path {
val baseDir = Path.of(properties.filesystem.baseDir)
return key.toPathParts().fold(baseDir) { acc, part -> acc.resolve(part) }
}
}

View File

@@ -0,0 +1,27 @@
package com.betriebsratkanzlei.legalconsenthub.file
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
@Component
class TemporaryFileCleanupScheduler(
private val fileService: FileService,
) {
private val logger = LoggerFactory.getLogger(TemporaryFileCleanupScheduler::class.java)
/**
* Delete temporary files older than 7 days
* Runs daily at 2 AM
*/
@Scheduled(cron = "0 0 2 * * *")
fun cleanupTemporaryFiles() {
logger.info("Starting temporary file cleanup job")
try {
fileService.deleteTemporaryFilesOlderThan(7)
logger.info("Temporary file cleanup job completed successfully")
} catch (e: Exception) {
logger.error("Temporary file cleanup job failed", e)
}
}
}

View File

@@ -0,0 +1,50 @@
package com.betriebsratkanzlei.legalconsenthub.file
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.user.User
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EntityListeners
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant
import java.util.UUID
@Entity
@EntityListeners(AuditingEntityListener::class)
@Table(name = "uploaded_file")
class UploadedFile(
@Id
@GeneratedValue
var id: UUID? = null,
@Column(nullable = false)
var filename: String,
@Column(nullable = false)
var originalFilename: String,
@Column(nullable = false)
var size: Long,
@Column(nullable = false)
var mimeType: String,
@Column(nullable = true)
var organizationId: String? = null,
@ManyToOne
@JoinColumn(name = "application_form_id", nullable = true)
var applicationForm: ApplicationForm? = null,
@Column(nullable = false)
var formElementReference: String,
@Column(nullable = false)
var storagePath: String,
@ManyToOne
@JoinColumn(name = "uploaded_by_id", nullable = true)
var uploadedBy: User? = null,
@CreatedDate
@Column(nullable = false)
var uploadedAt: Instant? = null,
@Column(nullable = false)
var isTemporary: Boolean = false,
)

View File

@@ -57,6 +57,17 @@ spring:
timeout: 5000
writetimeout: 5000
servlet:
multipart:
max-file-size: 10MB
max-request-size: 50MB
legalconsenthub:
file:
storage:
filesystem:
base-dir: ${LEGALCONSENTHUB_FILE_STORAGE_FILESYSTEM_BASE_DIR:./data/files}
management:
health:
mail:

View File

@@ -82,7 +82,7 @@ create table form_element
form_element_order integer,
is_clonable boolean not null,
row_preset_filter_src_col_idx integer,
type smallint not null check (type between 0 and 8),
type smallint not null check (type between 0 and 9),
form_element_sub_section_id uuid not null,
id uuid not null,
description varchar(255),
@@ -152,6 +152,23 @@ create table table_column_mappings
form_element_id uuid not null
);
create table uploaded_file
(
is_temporary boolean not null,
size bigint not null,
uploaded_at timestamp(6) with time zone not null,
application_form_id uuid,
id uuid not null,
filename varchar(255) not null,
form_element_reference varchar(255) not null,
mime_type varchar(255) not null,
organization_id varchar(255),
original_filename varchar(255) not null,
storage_path varchar(255) not null,
uploaded_by_id varchar(255),
primary key (id)
);
create table visibility_conditions
(
form_element_id uuid not null,
@@ -233,6 +250,16 @@ alter table if exists table_column_mappings
foreign key (form_element_id)
references form_element;
alter table if exists uploaded_file
add constraint FKn866ru0c9ygi5wsqvliv181uj
foreign key (application_form_id)
references application_form;
alter table if exists uploaded_file
add constraint FKtg323a9339lx0do79gu4eftao
foreign key (uploaded_by_id)
references app_user;
alter table if exists visibility_conditions
add constraint FK5xuf7bd179ogpq5a1m3g8q7jb
foreign key (form_element_id)

View File

@@ -4839,3 +4839,14 @@ formElementSections:
label: Nein
processingPurpose: DATA_ANALYSIS
employeeDataCategory: SENSITIVE
- reference: ki_dokumentation
title: Dokumentation zur Grundrechte-Folgenabschätzung
description: Bitte laden Sie die Dokumentation zur Grundrechte-Folgenabschätzung hoch
type: FILE_UPLOAD
visibilityConditions:
- formElementConditionType: SHOW
sourceFormElementReference: ki_info_grundrechte_folgenabschaetzung
formElementExpectedValue: Ja (bitte beilegen)
formElementOperator: EQUALS
options: []