meals-made-easy-api/src/main/java/app/mealsmadeeasy/api/image/S3ImageService.java

254 lines
10 KiB
Java

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<User> viewersToAdd = spec.getViewersToAdd();
if (viewersToAdd != null) {
final Set<User> 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<Image> 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<User> viewersToRemove = updateSpec.getViewersToRemove();
if (viewersToRemove != null) {
final Set<User> 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);
}
}
}