Adds file type detection, and beefs up error states for file uploads

This commit is contained in:
2026-05-14 11:13:19 -05:00
parent 5ca7a685dd
commit 1dd904055c
6 changed files with 80 additions and 7 deletions

View File

@@ -34,6 +34,7 @@ dependencies {
val springdocUi = "3.0.3" val springdocUi = "3.0.3"
val awsSdk = "2.26.0" val awsSdk = "2.26.0"
val thumbnailator = "0.4.20" val thumbnailator = "0.4.20"
val tika = "3.3.0"
implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
@@ -51,6 +52,7 @@ dependencies {
implementation(platform("software.amazon.awssdk:bom:$awsSdk")) implementation(platform("software.amazon.awssdk:bom:$awsSdk"))
implementation("software.amazon.awssdk:s3") implementation("software.amazon.awssdk:s3")
implementation("net.coobird:thumbnailator:$thumbnailator") implementation("net.coobird:thumbnailator:$thumbnailator")
implementation("org.apache.tika:tika-core:$tika")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
testImplementation("org.springframework.boot:spring-boot-starter-actuator-test") testImplementation("org.springframework.boot:spring-boot-starter-actuator-test")

View File

@@ -1,5 +1,6 @@
package net.halfbinary.scavengerhuntapi.error package net.halfbinary.scavengerhuntapi.error
import net.halfbinary.scavengerhuntapi.error.exception.BadFileException
import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException
import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
@@ -12,6 +13,8 @@ import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.multipart.MaxUploadSizeExceededException
import java.net.SocketTimeoutException
@RestControllerAdvice @RestControllerAdvice
@@ -68,6 +71,24 @@ class ExceptionHandler {
} }
} }
@ExceptionHandler(BadFileException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun badFileException(e: BadFileException): String? {
return e.message
}
@ExceptionHandler(MaxUploadSizeExceededException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun maxUploadSizeExceededException(e: MaxUploadSizeExceededException): String? {
return e.message
}
@ExceptionHandler(SocketTimeoutException::class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
fun socketTimeoutException(): String {
return "Unable to connect. Try again later."
}
private fun simpleMap(key: String, value: String?): Map<String, String?> { private fun simpleMap(key: String, value: String?): Map<String, String?> {
return mapOf(Pair(key, value)) return mapOf(Pair(key, value))
} }

View File

@@ -0,0 +1,3 @@
package net.halfbinary.scavengerhuntapi.error.exception
class BadFileException(override val message: String): RuntimeException(message)

View File

@@ -0,0 +1,22 @@
package net.halfbinary.scavengerhuntapi.service
import org.apache.tika.Tika
import org.apache.tika.config.TikaConfig
import org.springframework.stereotype.Service
@Service
class FileProbeService {
private val tika = Tika()
fun getFileType(fileBytes: ByteArray): String {
return tika.detect(fileBytes)
}
fun getFileExtension(fileType: String): String {
return TikaConfig.getDefaultConfig().mimeRepository.forName(fileType).extension
}
fun isImageType(fileType: String): Boolean {
return fileType.startsWith("image")
}
}

View File

@@ -1,6 +1,8 @@
package net.halfbinary.scavengerhuntapi.service package net.halfbinary.scavengerhuntapi.service
import net.coobird.thumbnailator.Thumbnails import net.coobird.thumbnailator.Thumbnails
import net.coobird.thumbnailator.tasks.UnsupportedFormatException
import net.halfbinary.scavengerhuntapi.error.exception.BadFileException
import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ItemId import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.PhotoStatus import net.halfbinary.scavengerhuntapi.model.PhotoStatus
@@ -18,9 +20,21 @@ import java.time.LocalDateTime
class PhotoService( class PhotoService(
private val photoRepository: PhotoRepository, private val photoRepository: PhotoRepository,
private val hunterService: HunterService, private val hunterService: HunterService,
private val s3StorageService: S3StorageService private val s3StorageService: S3StorageService,
private val fileProbeService: FileProbeService
) { ) {
fun submitPhoto(huntId: HuntId, itemId: ItemId, email: String, file: MultipartFile): Photo { fun submitPhoto(huntId: HuntId, itemId: ItemId, email: String, file: MultipartFile): Photo {
val originalBytes = file.bytes
val fileType = fileProbeService.getFileType(originalBytes)
if(!fileProbeService.isImageType(fileType)) throw BadFileException("Not an image")
val originalAsJpeg = try {
toJpeg(file.bytes)
} catch (_: UnsupportedFormatException) {
throw BadFileException("Image type is not supported")
}
val hunter = hunterService.getHunterByEmail(email) val hunter = hunterService.getHunterByEmail(email)
val now = LocalDateTime.now() val now = LocalDateTime.now()
val photo = Photo( val photo = Photo(
@@ -34,14 +48,25 @@ class PhotoService(
val savedRecord = photoRepository.save(photo.toRecord()) val savedRecord = photoRepository.save(photo.toRecord())
val baseName = savedRecord.id.toString() val baseName = savedRecord.id.toString()
val originalBytes = file.bytes
s3StorageService.upload("$baseName.jpg", originalBytes, MediaType.IMAGE_JPEG_VALUE)
s3StorageService.upload("${baseName}_medium.jpg", resize(originalBytes, 800), MediaType.IMAGE_JPEG_VALUE) s3StorageService.upload("$baseName${fileProbeService.getFileExtension(fileType)}", originalBytes, fileType)
s3StorageService.upload("${baseName}_thumb.jpg", resize(originalBytes, 200), MediaType.IMAGE_JPEG_VALUE) s3StorageService.upload("${baseName}_large.jpg", originalAsJpeg, MediaType.IMAGE_JPEG_VALUE)
s3StorageService.upload("${baseName}_medium.jpg", resize(originalBytes, 800), MediaType.IMAGE_JPEG_VALUE)
s3StorageService.upload("${baseName}_small.jpg", resize(originalBytes, 200), MediaType.IMAGE_JPEG_VALUE)
return photo return photo
} }
private fun toJpeg(bytes: ByteArray): ByteArray {
val output = ByteArrayOutputStream()
Thumbnails.of(ByteArrayInputStream(bytes))
.scale(1.0)
.outputFormat("jpg")
.toOutputStream(output)
return output.toByteArray()
}
private fun resize(bytes: ByteArray, width: Int): ByteArray { private fun resize(bytes: ByteArray, width: Int): ByteArray {
val output = ByteArrayOutputStream() val output = ByteArrayOutputStream()
Thumbnails.of(ByteArrayInputStream(bytes)) Thumbnails.of(ByteArrayInputStream(bytes))

View File

@@ -17,8 +17,8 @@ minio.access-key=${MINIO_ACCESS_KEY}
minio.secret-key=${MINIO_SECRET_KEY} minio.secret-key=${MINIO_SECRET_KEY}
minio.bucket=${MINIO_BUCKET} minio.bucket=${MINIO_BUCKET}
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-file-size=25MB
spring.servlet.multipart.max-request-size=10MB spring.servlet.multipart.max-request-size=25MB
springdoc.api-docs.enabled=true springdoc.api-docs.enabled=true
springdoc.api-docs.path=/docs/api-docs springdoc.api-docs.path=/docs/api-docs