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)
                ))
        ));
    }


}