diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java new file mode 100644 index 0000000..07365a0 --- /dev/null +++ b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java @@ -0,0 +1,136 @@ +package app.mealsmadeeasy.api.image; + +import app.mealsmadeeasy.api.auth.AuthService; +import app.mealsmadeeasy.api.auth.LoginException; +import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.user.UserCreateException; +import app.mealsmadeeasy.api.user.UserService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.io.InputStream; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@Testcontainers +@SpringBootTest +@AutoConfigureMockMvc +public class ImageControllerTests { + + private static final String USER_FILENAME = "HAL9000.svg"; + + @Container + private static final MinIOContainer container = new MinIOContainer( + DockerImageName.parse("minio/minio:latest") + ); + + @DynamicPropertySource + public static void minioProperties(DynamicPropertyRegistry registry) { + registry.add("app.mealsmadeeasy.api.minio.endpoint", container::getS3URL); + registry.add("app.mealsmadeeasy.api.minio.accessKey", container::getUserName); + registry.add("app.mealsmadeeasy.api.minio.secretKey", container::getPassword); + } + + private static InputStream getHal9000() { + return ImageControllerTests.class.getResourceAsStream("HAL9000.svg"); + } + + @Autowired + private UserService userService; + + @Autowired + private AuthService authService; + + @Autowired + private ImageService imageService; + + @Autowired + private MockMvc mockMvc; + + private User createTestUser(String username) { + try { + return this.userService.createUser(username, username + "@test.com", "test"); + } catch (UserCreateException e) { + throw new RuntimeException(e); + } + } + + private Image createHal9000(User owner) throws ImageException, IOException { + try (final InputStream hal9000 = getHal9000()) { + return this.imageService.create( + owner, + USER_FILENAME, + hal9000, + "image/svg+xml", + 27881L + ); + } + } + + private String getAccessToken(String username) { + try { + return this.authService.login(username, "test").getAccessToken().getToken(); + } catch (LoginException e) { + throw new RuntimeException(e); + } + } + + @Test + @DirtiesContext + public void getImageNoPrincipal() throws Exception { + final User owner = this.createTestUser("imageOwner"); + final Image image = this.createHal9000(owner); + this.imageService.setPublic(image, owner, true); + try (final InputStream hal9000 = getHal9000()) { + final byte[] halBytes = hal9000.readAllBytes(); + this.mockMvc.perform(get("/images/imageOwner/HAL9000.svg")) + .andExpect(status().isOk()) + .andExpect(content().contentType("image/svg+xml")) + .andExpect(content().bytes(halBytes)); + } + } + + private void doGetImageTestWithViewer(String accessToken) throws Exception { + try (final InputStream hal9000 = getHal9000()) { + final byte[] halBytes = hal9000.readAllBytes(); + this.mockMvc.perform(get("/images/imageOwner/HAL9000.svg") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(content().contentType("image/svg+xml")) + .andExpect(content().bytes(halBytes)); + } + } + + @Test + @DirtiesContext + public void getImageWithOwner() throws Exception { + final User owner = this.createTestUser("imageOwner"); + this.createHal9000(owner); + final String accessToken = this.getAccessToken(owner.getUsername()); + this.doGetImageTestWithViewer(accessToken); + } + + @Test + @DirtiesContext + public void getImageWithViewer() throws Exception { + final User owner = this.createTestUser("imageOwner"); + final User viewer = this.createTestUser("viewer"); + final Image image = this.createHal9000(owner); + this.imageService.addViewer(image, owner, viewer); + final String accessToken = this.getAccessToken(viewer.getUsername()); + this.doGetImageTestWithViewer(accessToken); + } + +} diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java index dadec3b..0769fb0 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/image/S3ImageServiceTests.java @@ -39,7 +39,6 @@ public class S3ImageServiceTests { @DynamicPropertySource public static void minioProperties(DynamicPropertyRegistry registry) { - registry.add("app.mealsmadeeasy.api.minio.bucketName", () -> "test-bucket"); registry.add("app.mealsmadeeasy.api.minio.endpoint", container::getS3URL); registry.add("app.mealsmadeeasy.api.minio.accessKey", container::getUserName); registry.add("app.mealsmadeeasy.api.minio.secretKey", container::getPassword); @@ -96,11 +95,11 @@ public class S3ImageServiceTests { @Test @DirtiesContext - public void loadImageWithOwner() throws ImageException, IOException { + public void loadImageWithOwnerAsViewer() throws ImageException, IOException { final User owner = this.createTestUser("imageOwner"); final Image image = this.createHal9000(owner); try (final InputStream stored = - this.imageService.getImageContentByOwnerAndFilename(owner, owner, image.getUserFilename())) { + this.imageService.getImageContent(image, owner)) { final byte[] storedBytes = stored.readAllBytes(); assertThat(storedBytes.length, is(27881)); } @@ -113,7 +112,7 @@ public class S3ImageServiceTests { Image image = this.createHal9000(owner); image = this.imageService.setPublic(image, owner, true); try (final InputStream stored = - this.imageService.getImageContentByOwnerAndFilename(owner, image.getUserFilename())) { + this.imageService.getImageContent(image, null)) { final byte[] storedBytes = stored.readAllBytes(); assertThat(storedBytes.length, is(27881)); } @@ -127,7 +126,7 @@ public class S3ImageServiceTests { Image image = this.createHal9000(owner); image = this.imageService.addViewer(image, owner, viewer); try (final InputStream stored = - this.imageService.getImageContentByOwnerAndFilename(viewer, owner, image.getUserFilename())) { + this.imageService.getImageContent(image, viewer)) { final byte[] storedBytes = stored.readAllBytes(); assertThat(storedBytes.length, is(27881)); } diff --git a/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java b/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java index 7ef3817..dbe8fee 100644 --- a/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java +++ b/src/main/java/app/mealsmadeeasy/api/DevConfiguration.java @@ -1,26 +1,37 @@ package app.mealsmadeeasy.api; +import app.mealsmadeeasy.api.image.Image; +import app.mealsmadeeasy.api.image.ImageException; +import app.mealsmadeeasy.api.image.ImageService; import app.mealsmadeeasy.api.recipe.Recipe; import app.mealsmadeeasy.api.recipe.RecipeService; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import java.io.IOException; +import java.io.InputStream; import java.util.Set; @Configuration @Profile("dev") public class DevConfiguration { + private static final Logger logger = LoggerFactory.getLogger(DevConfiguration.class); + private final UserService userService; private final RecipeService recipeService; + private final ImageService imageService; - public DevConfiguration(UserService userService, RecipeService recipeService) { + public DevConfiguration(UserService userService, RecipeService recipeService, ImageService imageService) { this.userService = userService; this.recipeService = recipeService; + this.imageService = imageService; } @Bean @@ -29,10 +40,25 @@ public class DevConfiguration { final User testUser = this.userService.createUser( "test", "test@test.com", "test", Set.of() ); + logger.info("Created {}", testUser); + final Recipe recipe = this.recipeService.create(testUser, "Test Recipe", "Hello, World!"); this.recipeService.setPublic(recipe, testUser, true); - System.out.println("Created " + testUser); - System.out.println("Created " + recipe); + logger.info("Created {}", recipe); + + try (final InputStream inputStream = DevConfiguration.class.getResourceAsStream("HAL9000.svg")) { + final Image image = this.imageService.create( + testUser, + "HAL9000.svg", + inputStream, + "image/svg+xml", + 27881L + ); + this.imageService.setPublic(image, testUser, true); + logger.info("Created {}", image); + } catch (IOException | ImageException e) { + logger.error("Failed to load and/or create HAL9000.svg", e); + } }; } diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageController.java b/src/main/java/app/mealsmadeeasy/api/image/ImageController.java new file mode 100644 index 0000000..9b9945a --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageController.java @@ -0,0 +1,43 @@ +package app.mealsmadeeasy.api.image; + +import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.user.UserService; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RestController; + +import java.io.IOException; +import java.io.InputStream; + +@RestController +@RequestMapping("/images") +public class ImageController { + + private final ImageService imageService; + private final UserService userService; + + public ImageController(ImageService imageService, UserService userService) { + this.imageService = imageService; + this.userService = userService; + } + + @GetMapping("/{username}/{filename}") + public ResponseEntity getImage( + @AuthenticationPrincipal User principal, + @PathVariable String username, + @PathVariable String filename + ) throws ImageException, IOException { + final User owner = this.userService.getUser(username); + final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal); + final InputStream imageInputStream = this.imageService.getImageContent(image, principal); + return ResponseEntity.status(200) + .contentType(MediaType.parseMediaType(image.getMimeType())) + .body(new InputStreamResource(imageInputStream)); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/image/ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/ImageService.java index adfe5c5..78847ee 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/ImageService.java @@ -1,6 +1,7 @@ package app.mealsmadeeasy.api.image; import app.mealsmadeeasy.api.user.User; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -11,13 +12,8 @@ public interface ImageService { Image create(User owner, String userFilename, InputStream inputStream, String mimeType, long objectSize) throws IOException, ImageException; - Image getById(long id) throws ImageException; - Image getById(long id, User viewer) throws ImageException; - Image getByOwnerAndFilename(User viewer, User owner, String filename) throws ImageException; - - InputStream getImageContentByOwnerAndFilename(User owner, String filename) throws ImageException, IOException; - InputStream getImageContentByOwnerAndFilename(User viewer, User owner, String filename) throws ImageException, IOException; - + Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException; + InputStream getImageContent(Image image, @Nullable User viewer) throws IOException; List getImagesOwnedBy(User user); Image updateOwner(Image image, User oldOwner, User newOwner); diff --git a/src/main/java/app/mealsmadeeasy/api/image/S3ImageEntity.java b/src/main/java/app/mealsmadeeasy/api/image/S3ImageEntity.java index e1fb283..07dd6a7 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/S3ImageEntity.java +++ b/src/main/java/app/mealsmadeeasy/api/image/S3ImageEntity.java @@ -145,7 +145,7 @@ public class S3ImageEntity implements Image { @Override public String toString() { - return "S3ImageEntity(" + this.id + ", " + this.userFilename + "," + this.objectName + ")"; + return "S3ImageEntity(" + this.id + ", " + this.userFilename + ", " + this.objectName + ")"; } } diff --git a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java index e922dce..0aeae0f 100644 --- a/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java +++ b/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java @@ -59,25 +59,9 @@ public class S3ImageService implements ImageService { return this.imageRepository.save(draft); } - @Override - @PostAuthorize("returnObject.isPublic") - public Image getById(long id) throws ImageException { - return this.imageRepository.findById(id).orElseThrow(() -> new ImageException( - ImageException.Type.INVALID_ID, "No such image with id " + id - )); - } - @Override @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") - public Image getById(long id, User viewer) throws ImageException { - return this.imageRepository.findById(id).orElseThrow(() -> new ImageException( - ImageException.Type.INVALID_ID, "No such image with id " + id - )); - } - - @Override - @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") - public Image getByOwnerAndFilename(User viewer, User owner, String filename) throws ImageException { + public Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException { return this.imageRepository.findByOwnerAndUserFilename((UserEntity) owner, filename) .orElseThrow(() -> new ImageException( ImageException.Type.IMAGE_NOT_FOUND, @@ -86,16 +70,9 @@ public class S3ImageService implements ImageService { } @Override - public InputStream getImageContentByOwnerAndFilename(User viewer, User owner, String filename) - throws ImageException, IOException { - final S3ImageEntity imageEntity = (S3ImageEntity) this.getByOwnerAndFilename(viewer, owner, filename); - return this.s3Manager.load(this.imageBucketName, imageEntity.getObjectName()); - } - - @Override - public InputStream getImageContentByOwnerAndFilename(User owner, String filename) throws ImageException, IOException { - final S3ImageEntity imageEntity = (S3ImageEntity) this.getByOwnerAndFilename(null, owner, filename); - return this.s3Manager.load(this.imageBucketName, imageEntity.getObjectName()); + @PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)") + public InputStream getImageContent(Image image, User viewer) throws IOException { + return this.s3Manager.load(this.imageBucketName, ((S3ImageEntity) image).getObjectName()); } @Override diff --git a/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java b/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java index 7ff17e8..c296a4b 100644 --- a/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java +++ b/src/main/java/app/mealsmadeeasy/api/security/SecurityConfiguration.java @@ -34,7 +34,13 @@ public class SecurityConfiguration { @Bean public WebSecurityCustomizer webSecurityCustomizer() { - return web -> web.ignoring().requestMatchers("/greeting", "/auth/**", "/sign-up/**", "/recipes/**"); + return web -> web.ignoring().requestMatchers( + "/greeting", + "/auth/**", + "/images/**", + "/recipes/**", + "/sign-up/**" + ); } @Bean diff --git a/src/main/resources/app/mealsmadeeasy/api/HAL9000.svg b/src/main/resources/app/mealsmadeeasy/api/HAL9000.svg new file mode 100644 index 0000000..fcd4f45 --- /dev/null +++ b/src/main/resources/app/mealsmadeeasy/api/HAL9000.svg @@ -0,0 +1,741 @@ + + + + + HAL9000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + HAL9000 + + + + MorningLemon + + + German + + + HAL + 9000 + HAL9000 + robot + space + + + The famous red eye of HAL 9000 from Stanley Kubricks Film "2001: A Space Odyssey". + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +