diff --git a/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/config/WebSecurityConfig.kt b/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/config/WebSecurityConfig.kt index 66ecad8..40f9ff5 100644 --- a/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/config/WebSecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/config/WebSecurityConfig.kt @@ -10,11 +10,11 @@ import dev.fyloz.trial.colorrecipesexplorer.service.EmployeeUserDetailsServiceIm import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm import org.slf4j.Logger -import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Lazy import org.springframework.core.env.Environment import org.springframework.http.HttpMethod import org.springframework.security.authentication.AuthenticationManager @@ -57,13 +57,12 @@ import javax.servlet.http.HttpServletResponse class WebSecurityConfig( val restAuthenticationEntryPoint: RestAuthenticationEntryPoint, val securityConfigurationProperties: SecurityConfigurationProperties, + @Lazy val userDetailsService: EmployeeUserDetailsServiceImpl, + @Lazy val employeeService: EmployeeServiceImpl, + val environment: Environment, val logger: Logger ) : WebSecurityConfigurerAdapter() { - @Autowired - private lateinit var userDetailsService: EmployeeUserDetailsServiceImpl - - @Autowired - private lateinit var employeeService: EmployeeServiceImpl + var debugMode = false override fun configure(authBuilder: AuthenticationManagerBuilder) { authBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()) @@ -98,7 +97,7 @@ class WebSecurityConfig( } @PostConstruct - fun createSystemUsers() { + fun initWebSecurity() { fun createUser( credentials: SecurityConfigurationProperties.SystemUserCredentials?, firstName: String, @@ -124,6 +123,8 @@ class WebSecurityConfig( } createUser(securityConfigurationProperties.root, "Root", "User", listOf(EmployeePermission.ADMIN)) + debugMode = "debug" in environment.activeProfiles + if (debugMode) logger.warn("Debug mode is enabled, security will be disabled!") } override fun configure(http: HttpSecurity) { @@ -145,13 +146,6 @@ class WebSecurityConfig( .headers().frameOptions().disable() .and() .csrf().disable() - .authorizeRequests() - .antMatchers(HttpMethod.GET, "/").permitAll() - .antMatchers("/api/login").permitAll() - .antMatchers("/api/employee/logout").permitAll() - .antMatchers(HttpMethod.GET, "/api/employee/current").authenticated() - .generateAuthorizations() - .and() .addFilter( JwtAuthenticationFilter( authenticationManager(), @@ -167,6 +161,18 @@ class WebSecurityConfig( ) ) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + + if (!debugMode) { + http.authorizeRequests() + .antMatchers(HttpMethod.GET, "/").permitAll() + .antMatchers("/api/login").permitAll() + .antMatchers("/api/employee/logout").permitAll() + .antMatchers(HttpMethod.GET, "/api/employee/current").authenticated() + .generateAuthorizations() + } else { + http.authorizeRequests() + .antMatchers("**").permitAll() + } } } @@ -179,20 +185,6 @@ class RestAuthenticationEntryPoint : AuthenticationEntryPoint { ) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized") } -class CorsFilter : Filter { - override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { - response as HttpServletResponse - - response.setHeader("Access-Control-Allow-Origin", "http://localhost:4200") - response.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT,OPTIONS") - response.setHeader("Access-Control-Allow-Headers", "*") - response.setHeader("Access-Control-Allow-Credentials", true.toString()) - response.setHeader("Access-Control-Max-Age", 180.toString()) - - chain.doFilter(request, response) - } -} - const val authorizationCookieName = "Authorization" const val defaultGroupCookieName = "Default-Group" val blacklistedJwtTokens = mutableListOf() @@ -207,7 +199,6 @@ class JwtAuthenticationFilter( init { setFilterProcessesUrl("/api/login") debugMode = "debug" in environment.activeProfiles - if (debugMode) logger.warn("Debug mode is enabled, cookies will not be secured!") } override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { diff --git a/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/model/Recipe.kt b/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/model/Recipe.kt index 7348862..17f894f 100644 --- a/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/model/Recipe.kt +++ b/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/model/Recipe.kt @@ -9,7 +9,6 @@ import javax.persistence.* import javax.validation.constraints.Min import javax.validation.constraints.NotBlank import javax.validation.constraints.NotNull -import javax.validation.constraints.Size private const val RECIPE_ID_NULL_MESSAGE = "Un identifiant est requis" private const val RECIPE_NAME_NULL_MESSAGE = "Un nom est requis" diff --git a/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/rest/RecipeController.kt b/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/rest/RecipeController.kt index 24c0100..acc6bea 100644 --- a/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/rest/RecipeController.kt +++ b/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/rest/RecipeController.kt @@ -2,12 +2,14 @@ package dev.fyloz.trial.colorrecipesexplorer.rest import dev.fyloz.trial.colorrecipesexplorer.model.* import dev.fyloz.trial.colorrecipesexplorer.service.MixService +import dev.fyloz.trial.colorrecipesexplorer.service.RecipeImageService import dev.fyloz.trial.colorrecipesexplorer.service.RecipeService +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile +import java.net.URI import javax.validation.Valid @@ -21,13 +23,43 @@ class RecipeController(recipeService: RecipeService) : recipeService, RECIPE_CONTROLLER_PATH ) { + @PutMapping("public") + @ResponseStatus(HttpStatus.NO_CONTENT) fun updatePublicData(@Valid @RequestBody publicDataDto: RecipePublicDataDto): ResponseEntity { service.updatePublicData(publicDataDto) return ResponseEntity.noContent().build() } } +@RestController +@RequestMapping(RECIPE_CONTROLLER_PATH) +class RecipeImageController(val recipeImageService: RecipeImageService) { + @GetMapping("{recipeId}/image") + @ResponseStatus(HttpStatus.OK) + fun getAllIdsForRecipe(@PathVariable recipeId: Long): ResponseEntity> = + ResponseEntity.ok(recipeImageService.getAllIdsForRecipe(recipeId)) + + @GetMapping("{recipeId}/image/{id}", produces = [MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE]) + @ResponseStatus(HttpStatus.OK) + fun getById(@PathVariable recipeId: Long, @PathVariable id: Long): ResponseEntity = + ResponseEntity.ok(recipeImageService.getByIdForRecipe(id, recipeId)) + + @PostMapping("{recipeId}/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @ResponseStatus(HttpStatus.CREATED) + fun save(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity { + val id = recipeImageService.save(image, recipeId) + return ResponseEntity.created(URI.create("$RECIPE_CONTROLLER_PATH/$recipeId/image/$id")).build() + } + + @DeleteMapping("{recipeId}/image/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun delete(@PathVariable recipeId: Long, @PathVariable id: Long): ResponseEntity { + recipeImageService.delete(id, recipeId) + return ResponseEntity.noContent().build() + } +} + @RestController @RequestMapping(MIX_CONTROLLER_PATH) class MixController(val mixService: MixService) : diff --git a/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/service/RecipeService.kt b/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/service/RecipeService.kt index 4c74ada..62e8cbc 100644 --- a/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/service/RecipeService.kt +++ b/src/main/kotlin/dev/fyloz/trial/colorrecipesexplorer/service/RecipeService.kt @@ -1,10 +1,15 @@ package dev.fyloz.trial.colorrecipesexplorer.service +import dev.fyloz.trial.colorrecipesexplorer.exception.model.EntityNotFoundRestException import dev.fyloz.trial.colorrecipesexplorer.model.* import dev.fyloz.trial.colorrecipesexplorer.model.validation.isNotNullAndNotBlank import dev.fyloz.trial.colorrecipesexplorer.model.validation.or import dev.fyloz.trial.colorrecipesexplorer.repository.RecipeRepository +import dev.fyloz.trial.colorrecipesexplorer.service.files.FilesService import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile +import java.io.File +import java.nio.file.NoSuchFileException import kotlin.contracts.ExperimentalContracts interface RecipeService : ExternalModelService { @@ -94,3 +99,63 @@ class RecipeServiceImpl( override fun removeMix(mix: Mix): Recipe = update(mix.recipe.apply { mixes.remove(mix) }) } + +const val RECIPE_IMAGES_DIRECTORY = "images/recipe" + +interface RecipeImageService { + fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray + + /** Gets the identifier of every images associated to the recipe with the given [recipeId]. */ + fun getAllIdsForRecipe(recipeId: Long): Collection + + /** Saves the given [image] and associate it to the recipe with the given [recipeId]. Returns the identifier of the saved image. */ + fun save(image: MultipartFile, recipeId: Long): Long + + /** Deletes the image with the given [recipeId] and [id]. */ + fun delete(id: Long, recipeId: Long) +} + +@Service +class RecipeImageServiceImpl(val recipeService: RecipeService, val filesService: FilesService) : RecipeImageService { + override fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray = + try { + filesService.readAsBytes(getPath(id, recipeId)) + } catch (ex: NoSuchFileException) { + throw EntityNotFoundRestException("$recipeId/$id") + } + + override fun getAllIdsForRecipe(recipeId: Long): Collection { + val recipe = recipeService.getById(recipeId) + val recipeDirectory = getRecipeDirectory(recipe.id!!) + if (!recipeDirectory.exists() || !recipeDirectory.isDirectory) { + return listOf() + } + return recipeDirectory.listFiles()!! // Should never be null because we check if recipeDirectory is a directory and exists before + .filterNotNull() + .map { it.name.toLong() } + } + + override fun save(image: MultipartFile, recipeId: Long): Long { + /** Gets the next id available for a new image for the recipe with the given [recipeId]. */ + fun getNextAvailableId(): Long = + with(getAllIdsForRecipe(recipeId)) { + if (isEmpty()) + 0 + else + maxOrNull()!! + 1L // maxOrNull() cannot return null because existingIds cannot be empty at this point + } + + val nextAvailableId = getNextAvailableId() + filesService.write(image, getPath(nextAvailableId, recipeId)) + return nextAvailableId + } + + override fun delete(id: Long, recipeId: Long) = + filesService.delete(getPath(id, recipeId)) + + /** Gets the images directory of the recipe with the given [recipeId]. */ + fun getRecipeDirectory(recipeId: Long) = File(filesService.getPath("$RECIPE_IMAGES_DIRECTORY/$recipeId")) + + /** Gets the file of the image with the given [recipeId] and [id]. */ + fun getPath(id: Long, recipeId: Long): String = filesService.getPath("$RECIPE_IMAGES_DIRECTORY/$recipeId/$id") +} diff --git a/src/test/kotlin/dev/fyloz/trial/colorrecipesexplorer/service/RecipeServiceTest.kt b/src/test/kotlin/dev/fyloz/trial/colorrecipesexplorer/service/RecipeServiceTest.kt index 178c7d4..d54f982 100644 --- a/src/test/kotlin/dev/fyloz/trial/colorrecipesexplorer/service/RecipeServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/trial/colorrecipesexplorer/service/RecipeServiceTest.kt @@ -1,10 +1,17 @@ package dev.fyloz.trial.colorrecipesexplorer.service import com.nhaarman.mockitokotlin2.* +import dev.fyloz.trial.colorrecipesexplorer.exception.model.EntityNotFoundRestException import dev.fyloz.trial.colorrecipesexplorer.model.* import dev.fyloz.trial.colorrecipesexplorer.repository.RecipeRepository +import dev.fyloz.trial.colorrecipesexplorer.service.files.FilesService +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.mock.web.MockMultipartFile +import java.io.File +import java.nio.file.NoSuchFileException import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -131,3 +138,141 @@ class RecipeServiceTest : } } } + +class RecipeImageServiceTest { + private val recipeService: RecipeService = mock() + private val fileService: FilesService = mock() + private val service = spy(RecipeImageServiceImpl(recipeService, fileService)) + + private val recipeId = 1L + private val imageId = 5L + private val imagePath = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$imageId" + private val recipe = recipe(id = recipeId) + private val recipeDirectory: File = mock() + private val imagesIds = listOf(1L, 3L, 10L, 21L) + private val imageData = byteArrayOf(64, 32, 16, 8, 4, 2, 1) + private val image = MockMultipartFile("$imageId", imageData) + + @AfterEach + internal fun tearDown() { + reset(recipeService, fileService, service, recipeDirectory) + } + + @Nested + inner class GetByIdForRecipe { + @Test + fun `returns data for the given recipe and image id red by the file service`() { + whenever(fileService.getPath(imagePath)).doReturn(imagePath) + whenever(fileService.readAsBytes(imagePath)).doReturn(imageData) + + val found = service.getByIdForRecipe(imageId, recipeId) + + assertEquals(imageData, found) + } + + @Test + fun `throws EntityNotFoundRestException when no image with the given recipe and image id exists`() { + doReturn(imagePath).whenever(service).getPath(imageId, recipeId) + whenever(fileService.readAsBytes(imagePath)).doThrow(NoSuchFileException(imagePath)) + + val exception = + assertThrows { service.getByIdForRecipe(imageId, recipeId) } + assertEquals("$recipeId/$imageId", exception.value) + } + } + + @Nested + inner class GetAllIdsForRecipe { + @Test + fun `returns a list containing all image's identifier of the images of the given recipe`() { + val expectedFiles = imagesIds.map { File(it.toString()) }.toTypedArray() + + whenever(recipeService.getById(recipeId)).doReturn(recipe) + whenever(recipeDirectory.exists()).doReturn(true) + whenever(recipeDirectory.isDirectory).doReturn(true) + whenever(recipeDirectory.listFiles()).doReturn(expectedFiles) + doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId) + + val found = service.getAllIdsForRecipe(recipeId) + + assertEquals(imagesIds, found) + } + + @Test + fun `returns an empty list when the given recipe's directory does not exists`() { + whenever(recipeService.getById(recipeId)).doReturn(recipe) + whenever(recipeDirectory.exists()).doReturn(false) + whenever(recipeDirectory.isDirectory).doReturn(true) + doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId) + + val found = service.getAllIdsForRecipe(recipeId) + + assertTrue(found.isEmpty()) + } + + @Test + fun `returns an empty list when the given recipe's directory is not a directory`() { + whenever(recipeService.getById(recipeId)).doReturn(recipe) + whenever(recipeDirectory.exists()).doReturn(true) + whenever(recipeDirectory.isDirectory).doReturn(false) + doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId) + + val found = service.getAllIdsForRecipe(recipeId) + + assertTrue(found.isEmpty()) + } + } + + @Nested + inner class Save { + @Test + fun `writes the given image to the file service with the expected path`() { + val expectedNextAvailableId = imagesIds.maxOrNull()!! + 1 + val imagePath = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$expectedNextAvailableId" + + doReturn(imagesIds).whenever(service).getAllIdsForRecipe(recipeId) + doReturn(imagePath).whenever(service).getPath(expectedNextAvailableId, recipeId) + + service.save(image, recipeId) + + verify(fileService).write(image, imagePath) + } + } + + @Nested + inner class Delete { + @Test + fun `deletes the image with the given recipe and image id from the file service`() { + doReturn(imagePath).whenever(service).getPath(imageId, recipeId) + + service.delete(imageId, recipeId) + + verify(fileService).delete(imagePath) + } + } + + @Nested + inner class GetRecipeDirectory { + @Test + fun `returns a file with the expected path`() { + val recipeDirectoryPath = "$RECIPE_IMAGES_DIRECTORY/$recipeId" + whenever(fileService.getPath(recipeDirectoryPath)).doReturn(recipeDirectoryPath) + + val found = service.getRecipeDirectory(recipeId) + + assertEquals(recipeDirectoryPath, found.path) + } + } + + @Nested + inner class GetPath { + @Test + fun `returns the expected path`() { + whenever(fileService.getPath(any())).doAnswer { it.arguments[0] as String } + + val found = service.getPath(imageId, recipeId) + + assertEquals(imagePath, found) + } + } +}