package app.mealsmadeeasy.api.image; import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec; import app.mealsmadeeasy.api.image.spec.ImageUpdateInfoSpec; import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.s3.S3Manager; import app.mealsmadeeasy.api.user.User; import org.jetbrains.annotations.Nullable; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.time.OffsetDateTime; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @Service public class S3ImageService implements ImageService { private static final Pattern extensionPattern = Pattern.compile(".+\\.(.+)$"); private static final String IMAGE_JPEG = "image/jpeg"; private static final String IMAGE_PNG = "image/png"; private static final String IMAGE_SVG = "image/svg+xml"; private static final String IMAGE_WEBP = "image/webp"; private final S3Manager s3Manager; private final S3ImageRepository imageRepository; private final String imageBucketName; private final String baseUrl; public S3ImageService( S3Manager s3Manager, S3ImageRepository imageRepository, @Value("${app.mealsmadeeasy.api.images.bucketName}") String imageBucketName, @Value("${app.mealsmadeeasy.api.baseUrl}") String baseUrl ) { this.s3Manager = s3Manager; this.imageRepository = imageRepository; this.imageBucketName = imageBucketName; this.baseUrl = baseUrl; } private String getMimeType(String userFilename) { final Matcher m = extensionPattern.matcher(userFilename); if (m.matches()) { final String extension = m.group(1); return switch (extension) { case "jpg", "jpeg" -> IMAGE_JPEG; case "png" -> IMAGE_PNG; case "svg" -> IMAGE_SVG; case "webp" -> IMAGE_WEBP; default -> throw new IllegalArgumentException("Cannot determine mime type for extension: " + extension); }; } else { throw new IllegalArgumentException("Cannot determine mime type for filename: " + userFilename); } } private String getExtension(String mimeType) throws ImageException { return switch (mimeType) { case IMAGE_JPEG -> "jpg"; case IMAGE_PNG -> "png"; case IMAGE_SVG -> "svg"; case IMAGE_WEBP -> "webp"; default -> throw new ImageException( ImageException.Type.UNSUPPORTED_IMAGE_TYPE, "Unsupported mime type: " + mimeType ); }; } private boolean transferFromSpec(S3ImageEntity entity, ImageCreateInfoSpec spec) { boolean didTransfer = false; if (spec.getAlt() != null) { entity.setAlt(spec.getAlt()); didTransfer = true; } if (spec.getCaption() != null) { entity.setCaption(spec.getCaption()); didTransfer = true; } if (spec.getPublic() != null) { entity.setPublic(spec.getPublic()); didTransfer = true; } final @Nullable Set viewersToAdd = spec.getViewersToAdd(); if (viewersToAdd != null) { final Set viewers = new HashSet<>(entity.getViewerEntities()); for (final User viewerToAdd : spec.getViewersToAdd()) { viewers.add((User) viewerToAdd); } entity.setViewers(viewers); didTransfer = true; } return didTransfer; } /** * @apiNote Consumes and closes the {@link java.io.InputStream}. * * @param owner the User owner * @param userFilename the name of the uploaded file from the user * @param inputStream the image content * @param objectSize the size of the image, in bytes * @param createSpec the metadata for the image * @return an {@link app.mealsmadeeasy.api.image.Image} representing the stored image * @throws IOException if there are any errors related to IO while storing the image * @throws ImageException if there are any errors processing the image */ @Override public Image create( User owner, String userFilename, InputStream inputStream, long objectSize, ImageCreateInfoSpec createSpec ) throws IOException, ImageException { final String mimeType = this.getMimeType(userFilename); final String uuid = UUID.randomUUID().toString(); final String extension = this.getExtension(mimeType); final String filename = uuid + "." + extension; final var baos = new ByteArrayOutputStream(); inputStream.transferTo(baos); final InputStream toStore = new ByteArrayInputStream(baos.toByteArray()); final InputStream toRead = new ByteArrayInputStream(baos.toByteArray()); final String objectName = this.s3Manager.store( this.imageBucketName, filename, mimeType, toStore, objectSize ); final BufferedImage bufferedImage = ImageIO.read(toRead); if (bufferedImage == null) { throw new ImageException( ImageException.Type.UNSUPPORTED_IMAGE_TYPE, "ImageIO could not read image: " + userFilename ); } final int height = bufferedImage.getHeight(); final int width = bufferedImage.getWidth(); toRead.close(); toStore.close(); inputStream.close(); final S3ImageEntity draft = new S3ImageEntity(); draft.setOwner((User) owner); draft.setUserFilename(userFilename); draft.setMimeType(mimeType); draft.setObjectName(objectName); draft.setHeight(height); draft.setWidth(width); this.transferFromSpec(draft, createSpec); return this.imageRepository.save(draft); } @Override @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") public Image getById(long id, @Nullable User viewer) throws ImageException { return this.imageRepository.findById(id).orElseThrow(() -> new ImageException( ImageException.Type.INVALID_ID, "No Image with id: " + id )); } @Override @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") public Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException { return this.imageRepository.findByOwnerAndUserFilename((User) owner, filename) .orElseThrow(() -> new ImageException( ImageException.Type.IMAGE_NOT_FOUND, "No such image for owner " + owner + " with filename " + filename )); } @Override @PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)") public Image getByUsernameAndFilename(String username, String filename, User viewer) throws ImageException { return this.imageRepository.findByOwnerUsernameAndFilename(username, filename).orElseThrow( () -> new ImageException( ImageException.Type.INVALID_USERNAME_OR_FILENAME, "No such Image for username " + username + " and filename " + filename ) ); } @Override @PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)") public InputStream getImageContent(Image image, User viewer) throws IOException { return this.s3Manager.load(this.imageBucketName, ((S3ImageEntity) image).getObjectName()); } @Override public List getImagesOwnedBy(User user) { return new ArrayList<>(this.imageRepository.findAllByOwner((User) user)); } @Override @PreAuthorize("@imageSecurity.isOwner(#image, #modifier)") public Image update(final Image image, User modifier, ImageUpdateInfoSpec updateSpec) { S3ImageEntity entity = (S3ImageEntity) image; boolean didUpdate = this.transferFromSpec(entity, updateSpec); final @Nullable Boolean clearAllViewers = updateSpec.getClearAllViewers(); if (clearAllViewers != null && clearAllViewers) { entity.setViewers(new HashSet<>()); didUpdate = true; } else { final @Nullable Set viewersToRemove = updateSpec.getViewersToRemove(); if (viewersToRemove != null) { final Set currentViewers = new HashSet<>(entity.getViewerEntities()); for (final User toRemove : updateSpec.getViewersToRemove()) { currentViewers.remove((User) toRemove); } entity.setViewers(currentViewers); didUpdate = true; } } if (didUpdate) { entity.setModified(OffsetDateTime.now()); } return this.imageRepository.save(entity); } @Override @PreAuthorize("@imageSecurity.isOwner(#image, #modifier)") public void deleteImage(Image image, User modifier) throws IOException { final S3ImageEntity imageEntity = (S3ImageEntity) image; this.imageRepository.delete(imageEntity); this.s3Manager.delete("images", imageEntity.getObjectName()); } private String getImageUrl(Image image) { return this.baseUrl + "/images/" + image.getOwner().getUsername() + "/" + image.getUserFilename(); } @Override public ImageView toImageView(Image image, @Nullable User viewer) { if (viewer != null && image.getOwner().getUsername().equals(viewer.getUsername())) { return ImageView.from(image, this.getImageUrl(image), true); } else { return ImageView.from(image, this.getImageUrl(image), false); } } }