From aff0872e380aa4501de56b42f2f478204659e68e Mon Sep 17 00:00:00 2001 From: aarbit Date: Thu, 14 May 2026 14:29:52 -0500 Subject: [PATCH] Adds image retrieval endpoint --- .../controller/AdminController.kt | 22 ++++++++++++++ .../controller/PhotoController.kt | 29 +++++++++++++++++++ .../controller/TeamController.kt | 10 ------- .../error/ExceptionHandler.kt | 7 +++++ .../scavengerhuntapi/model/ImageVersion.kt | 5 ++++ .../model/domain/PhotoFile.kt | 6 ++++ .../scavengerhuntapi/service/PhotoService.kt | 29 ++++++++++++++++++- .../service/S3StorageService.kt | 24 +++++++++++++++ 8 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/AdminController.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/PhotoController.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/ImageVersion.kt create mode 100644 src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/PhotoFile.kt diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/AdminController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/AdminController.kt new file mode 100644 index 0000000..ebe7d6b --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/AdminController.kt @@ -0,0 +1,22 @@ +package net.halfbinary.scavengerhuntapi.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import net.halfbinary.scavengerhuntapi.model.PhotoId +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("admin") +class AdminController { + @PreAuthorize("hasRole('ADMIN')") + @Tag(name = "Admin") + @PostMapping("/admin/photo/{photoId}") + @Operation(summary = "Sets a review status for the specified photo") + fun reviewPhoto(@PathVariable photoId: PhotoId) { + TODO("Set a review status for the specified photo, and update the photo record's status change timestamp") + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/PhotoController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/PhotoController.kt new file mode 100644 index 0000000..94c72a0 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/PhotoController.kt @@ -0,0 +1,29 @@ +package net.halfbinary.scavengerhuntapi.controller + +import io.swagger.v3.oas.annotations.Operation +import net.halfbinary.scavengerhuntapi.model.ImageVersion +import net.halfbinary.scavengerhuntapi.model.PhotoId +import net.halfbinary.scavengerhuntapi.service.PhotoService +import org.springframework.core.io.InputStreamSource +import org.springframework.http.ResponseEntity +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("photo") +class PhotoController(private val photoService: PhotoService) { + @GetMapping("/{photoId}/file") + @Operation(summary = "Get the binary image information for the specified Photo") + fun getPhoto(authentication: Authentication, + @PathVariable photoId: PhotoId, + @RequestParam(defaultValue = "LARGE") version: ImageVersion): ResponseEntity { + val photoFile = photoService.getPhotoFile(photoId, authentication.name, version) + return ResponseEntity.ok() + .contentType(photoFile.contentType) + .body(photoFile.resource) + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt index 3aa6109..6cd9a8e 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt @@ -13,7 +13,6 @@ import net.halfbinary.scavengerhuntapi.model.response.TeamItemResponse import net.halfbinary.scavengerhuntapi.model.response.TeamResponse import net.halfbinary.scavengerhuntapi.service.PhotoService import net.halfbinary.scavengerhuntapi.service.TeamService -import org.springframework.core.io.InputStreamSource import org.springframework.http.ResponseEntity import org.springframework.security.core.Authentication import org.springframework.web.bind.annotation.GetMapping @@ -74,15 +73,6 @@ class TeamController(private val teamService: TeamService, private val photoServ return ResponseEntity.ok(photoService.getPhotoInfo(huntId, teamId, itemId, photoId, authentication.name)) } - @GetMapping("/{teamId}/item/{itemId}/photo/{photoId}/file") - @Operation(summary = "Get the binary image information for the specified Team, Hunt, Item, and Photo") - fun getPhoto(@PathVariable huntId: HuntId, - @PathVariable teamId: TeamId, - @PathVariable itemId: ItemId, - @PathVariable photoId: PhotoId): ResponseEntity { - TODO("Get the binary image information for the specified Team, Hunt, Item, and Photo") - } - @PostMapping("/{teamId}/item/{itemId}/photo") @Operation(summary = "Save photo information and store the binary file") fun submitPhoto(@PathVariable huntId: HuntId, diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt index 504ddfb..9c58fc8 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt @@ -14,6 +14,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException import org.springframework.web.multipart.MaxUploadSizeExceededException import java.net.SocketTimeoutException @@ -96,6 +97,12 @@ class ExceptionHandler { return "Unable to connect. Try again later." } + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun argumentMismatchException(): String? { + return "Invalid parameter value." + } + private fun simpleMap(key: String, value: String?): Map { return mapOf(Pair(key, value)) } diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/ImageVersion.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/ImageVersion.kt new file mode 100644 index 0000000..6949c2e --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/ImageVersion.kt @@ -0,0 +1,5 @@ +package net.halfbinary.scavengerhuntapi.model + +enum class ImageVersion { + ORIGINAL, LARGE, MEDIUM, SMALL +} diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/PhotoFile.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/PhotoFile.kt new file mode 100644 index 0000000..024eb51 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/PhotoFile.kt @@ -0,0 +1,6 @@ +package net.halfbinary.scavengerhuntapi.model.domain + +import org.springframework.core.io.InputStreamResource +import org.springframework.http.MediaType + +data class PhotoFile(val resource: InputStreamResource, val contentType: MediaType) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/PhotoService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/PhotoService.kt index 2f7ae67..3cabbb6 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/PhotoService.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/PhotoService.kt @@ -7,15 +7,19 @@ import net.halfbinary.scavengerhuntapi.error.exception.ForbiddenException import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.ItemId +import net.halfbinary.scavengerhuntapi.model.ImageVersion import net.halfbinary.scavengerhuntapi.model.PhotoId import net.halfbinary.scavengerhuntapi.model.PhotoStatus import net.halfbinary.scavengerhuntapi.model.TeamId +import net.halfbinary.scavengerhuntapi.model.domain.PhotoFile import net.halfbinary.scavengerhuntapi.model.converter.toDomain import net.halfbinary.scavengerhuntapi.model.converter.toRecord import net.halfbinary.scavengerhuntapi.model.converter.toResponse import net.halfbinary.scavengerhuntapi.model.domain.Photo import net.halfbinary.scavengerhuntapi.model.response.PhotoResponse import net.halfbinary.scavengerhuntapi.repository.PhotoRepository +import org.springframework.core.io.InputStreamResource +import org.springframework.data.repository.findByIdOrNull import org.springframework.http.MediaType import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile @@ -56,7 +60,7 @@ class PhotoService( val savedRecord = photoRepository.save(photo.toRecord()) val baseName = savedRecord.id.toString() - s3StorageService.upload("$baseName${fileProbeService.getFileExtension(fileType)}", originalBytes, fileType) + s3StorageService.upload("${baseName}_original${fileProbeService.getFileExtension(fileType)}", originalBytes, fileType) 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) @@ -76,6 +80,29 @@ class PhotoService( return photoRecord.toDomain().toResponse(submitter) } + fun getPhotoFile(photoId: PhotoId, email: String, version: ImageVersion = ImageVersion.LARGE): PhotoFile { + val requestingHunter = hunterService.getHunterByEmail(email) + val photoRecord = photoRepository.findByIdOrNull(photoId) + ?: throw NotFoundException("Photo not found") + + if (!requestingHunter.isAdmin) { + val submitter = hunterService.getHunterById(photoRecord.hunterId) + val requestingTeam = teamService.getTeamForHunterInHunt(photoRecord.huntId, requestingHunter.email) + val submitterTeam = teamService.getTeamForHunterInHunt(photoRecord.huntId, submitter.email) + if (requestingTeam.id != submitterTeam.id) throw ForbiddenException("Access denied") + } + + val key = when (version) { + ImageVersion.ORIGINAL -> s3StorageService.findKeyByPrefix("${photoId}_original") + ?: throw NotFoundException("Photo file not found") + ImageVersion.LARGE -> "${photoId}_large.jpg" + ImageVersion.MEDIUM -> "${photoId}_medium.jpg" + ImageVersion.SMALL -> "${photoId}_small.jpg" + } + val (stream, contentType) = s3StorageService.download(key) + return PhotoFile(InputStreamResource(stream), MediaType.parseMediaType(contentType)) + } + private fun toJpeg(bytes: ByteArray): ByteArray { val output = ByteArrayOutputStream() Thumbnails.of(ByteArrayInputStream(bytes)) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/S3StorageService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/S3StorageService.kt index b3fd66a..bcd4565 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/S3StorageService.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/S3StorageService.kt @@ -4,7 +4,10 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import software.amazon.awssdk.core.sync.RequestBody import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.GetObjectRequest +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request import software.amazon.awssdk.services.s3.model.PutObjectRequest +import java.io.InputStream @Service class S3StorageService( @@ -22,4 +25,25 @@ class S3StorageService( RequestBody.fromBytes(bytes) ) } + + fun findKeyByPrefix(prefix: String): String? { + val response = s3Client.listObjectsV2( + ListObjectsV2Request.builder() + .bucket(bucket) + .prefix(prefix) + .maxKeys(1) + .build() + ) + return response.contents().firstOrNull()?.key() + } + + fun download(key: String): Pair { + val response = s3Client.getObject( + GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build() + ) + return Pair(response, response.response().contentType()) + } }