S3Storage.java
package access.api;
import access.exception.InvalidInputException;
import tools.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import net.coobird.thumbnailator.Thumbnails;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.auth.signer.AwsS3V4Signer;
import software.amazon.awssdk.core.client.config.SdkAdvancedClientOption;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URI;
import java.util.Base64;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class S3Storage {
//Logos are resized down to 200x200 - there is no legitimate reason for the client to submit anything
//remotely close to these limits, so reject early rather than decode/decompress an oversized payload
private static final int MAX_LOGO_BASE64_LENGTH = 8 * 1024 * 1024;
private static final int MAX_LOGO_DECODED_BYTES = 6 * 1024 * 1024;
//~4000x3000 (12MP, a generous allowance for a high-res source photo) - a decoded ARGB raster at this cap is
//~48MB, not the ~190MB the previous 50M-pixel cap permitted, which defeated much of the point of this check
private static final long MAX_LOGO_PIXELS = 12_000_000L;
private final String bucketName;
private final String s3URL;
private final ObjectMapper objectMapper;
private final S3Client s3Client;
private boolean bucketExists;
@SneakyThrows
@SuppressWarnings("deprecation")
public S3Storage(@Value("${s3storage.url}") String s3URL,
@Value("${s3storage.key}") String s3AccessKey,
@Value("${s3storage.secret}") String s3SecretKey,
@Value("${s3storage.bucket}") String s3BucketName,
ObjectMapper objectMapper) {
this.s3URL = s3URL;
AwsBasicCredentials credentials =
AwsBasicCredentials.create(s3AccessKey, s3SecretKey);
this.s3Client = S3Client.builder()
.endpointOverride(new URI(s3URL))
.region(Region.EU_CENTRAL_1)
.forcePathStyle(true)
.overrideConfiguration(c -> {
c.putAdvancedOption(SdkAdvancedClientOption.SIGNER,
AwsS3V4Signer.create());
})
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
this.bucketName = s3BucketName;
this.objectMapper = objectMapper;
}
@SneakyThrows
public String uploadFile(String content) {
if (content.length() > MAX_LOGO_BASE64_LENGTH) {
throw new InvalidInputException("Logo content exceeds maximum allowed size");
}
byte[] decodedBytes = Base64.getDecoder().decode(content);
if (decodedBytes.length > MAX_LOGO_DECODED_BYTES) {
throw new InvalidInputException("Logo content exceeds maximum allowed size");
}
assertValidAndBoundedImage(decodedBytes);
if (!bucketExists) {
createBucket(s3Client);
}
ByteArrayInputStream inputStream = new ByteArrayInputStream(decodedBytes);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Thumbnails.of(inputStream)
.size(200, 200)
.keepAspectRatio(true)
.toOutputStream(baos);
inputStream = new ByteArrayInputStream(baos.toByteArray());
String uuid = UUID.randomUUID().toString();
s3Client.putObject(PutObjectRequest.builder()
.bucket(bucketName)
.key(uuid)
.contentType("image/jpeg")
.cacheControl("public, max-age=31536000, immutable")
.build(),
RequestBody.fromInputStream(inputStream, inputStream.available()));
return String.format("%s/%s/%s", s3URL, bucketName, uuid);
}
//Reads only the image header (width/height), not the full decompressed pixel buffer, so a small but highly
//compressed "decompression bomb" image can be rejected before Thumbnails decodes it into memory
private void assertValidAndBoundedImage(byte[] decodedBytes) throws java.io.IOException {
try (ImageInputStream imageInputStream = ImageIO.createImageInputStream(new ByteArrayInputStream(decodedBytes))) {
if (imageInputStream == null) {
throw new InvalidInputException("Logo content is not a valid image");
}
Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInputStream);
if (!readers.hasNext()) {
throw new InvalidInputException("Logo content is not a valid image");
}
ImageReader reader = readers.next();
try {
reader.setInput(imageInputStream);
long pixels = (long) reader.getWidth(0) * (long) reader.getHeight(0);
if (pixels > MAX_LOGO_PIXELS) {
throw new InvalidInputException("Logo image dimensions exceed maximum allowed size");
}
} finally {
reader.dispose();
}
}
}
private void createBucket(S3Client s3Client) {
HeadBucketRequest headBucketRequest = HeadBucketRequest.builder()
.bucket(bucketName)
.build();
try {
s3Client.headBucket(headBucketRequest);
} catch (NoSuchBucketException e) {
s3Client.createBucket(CreateBucketRequest.builder()
.bucket(bucketName)
.build());
PutBucketPolicyRequest putBucketPolicyRequest = PutBucketPolicyRequest
.builder()
.bucket(bucketName)
.policy(getPublicBucketPolicy(bucketName))
.build();
s3Client.putBucketPolicy(putBucketPolicyRequest);
bucketExists = true;
}
}
@SneakyThrows
private String getPublicBucketPolicy(String bucketName) {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(Map.of(
"Version", "2012-10-17",
"Statement", List.of(Map.of(
"Effect", "Allow",
"Principal", Map.of("AWS", "*"),
"Action", "s3:GetObject",
"Resource", String.format("arn:aws:s3:::%s/*", bucketName)
))
));
}
}