feat(fullstack): Add file upload
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class FileStorageException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : RuntimeException(message, cause)
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class FileTooLargeException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.error
|
||||
|
||||
class UnsupportedFileTypeException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: []
|
||||
|
||||
Reference in New Issue
Block a user