10

I want to store user profile pictures in an S3 bucket, but keep these images private. In order to do this, I am creating a presigned url whenever the image is required. However, this creates a unique url each time which means the image will never be cached by the browser and I'll end up paying a lot more in GET requests.

Here's an example of my code to generate the url, I'm using Laravel:

$s3 = \Storage::disk('s3');
$client = $s3->getDriver()->getAdapter()->getClient();
$expiry = new \DateTime('2017-07-25');

$command = $client->getCommand('GetObject', [
    'Bucket' => \Config::get('filesystems.disks.s3.bucket'),
    'Key'    => $key
]);

$request = $client->createPresignedRequest($command, $expiry);

return (string) $request->getUri();

I thought that by specifying a datetime rather a unit of time that it would create the same url but it actually adds the number of seconds remaining to the url, here's an example:

xxxx.s3.eu-west-2.amazonaws.com/profile-pics/92323.png?X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AXXXXXXXXXXX%2Feu-west-2%2Fs3%2Faws4_request&X-Amz-Date=20170720T112123Z&X-Amz-SignedHeaders=host&X-Amz-Expires=391117&X-Amz-Signature=XXXXXXXXX

Is it possible to generate a repeatable presigned request url so that an image may be cached by the users browser?

Erazihel
  • 7,295
  • 6
  • 30
  • 53
ezero
  • 1,230
  • 3
  • 12
  • 27
  • You can check my answer here : https://stackoverflow.com/questions/53205295/how-to-make-browser-cache-identical-image-with-different-aws-s3-presigned-url/55876166#55876166 – Ahmed Safar Apr 27 '19 at 01:52
  • Does this answer your question? [How to make browser cache identical image with different aws s3 presigned url?](https://stackoverflow.com/questions/53205295/how-to-make-browser-cache-identical-image-with-different-aws-s3-presigned-url) – Archimedes Trajano Feb 13 '21 at 15:14

5 Answers5

7

Here's a solution in python I came up with after following this post. It uses the freezegun library to manipulate the time to make the signature the same over a given period.

import time
import datetime

import boto3
from freezegun import freezetime


S3_CLIENT = boto3.client("s3")

SEVEN_DAYS_IN_SECONDS = 604800
MAX_EXPIRES_SECONDS = SEVEN_DAYS_IN_SECONDS



def get_presigned_get_url(bucket: str, key: str, expires_in_seconds: int = MAX_EXPIRES_SECONDS) -> str:
        current_timestamp = int(time.time())
        truncated_timestamp = current_timestamp - (current_timestamp % expires_in_seconds)
        with freeze_time(datetime.datetime.fromtimestamp(truncated_timestamp)):
            presigned_url = S3_CLIENT.generate_presigned_url(
                ClientMethod="get_object",
                Params={
                    "Bucket": bucket,
                    "Key": key,
                    "ResponseCacheControl": f"private, max-age={expires_in_seconds}, immutable",
                },
                ExpiresIn=expires_in_seconds,
                HttpMethod="GET",
            )
        return presigned_url
monkut
  • 42,176
  • 24
  • 124
  • 155
3

Maybe a late reply, but I'll add my approach for the benefit of people reading this in future.

To force the browser cache to kick in, it's important to generate same exact url every time until you specifically want the browser to reload content from the server. Unfortunately the presigner provided in the sdk, relies on current timestamp leading to a new url every time.

This example is in Java but it can easily be extended to other languages

The GetObjectRequest builder(used to create the presigned url) allows overriding configuration. We can supply a custom signer to modify its behaviour

AwsRequestOverrideConfiguration.builder()
    .signer(new CustomAwsS3V4Signer())
    .credentialsProvider(<You may need to provide a custom credential provider 
here>)))
.build())

GetObjectRequest getObjectRequest =
    GetObjectRequest.builder()
            .bucket(getUserBucket())
            .key(key)
            .responseCacheControl("max-age="+(TimeUnit.DAYS.toSeconds(7)+ defaultIfNull(version,0L)))
            .overrideConfiguration(overrideConfig)
            .build();

public class CustomAwsS3V4Signer implements Presigner, Signer
{
    private final AwsS3V4Signer awsSigner;

    public CustomAwsS3V4Signer()
    {
        awsSigner = AwsS3V4Signer.create();
    }

@Override
public SdkHttpFullRequest presign(SdkHttpFullRequest request, ExecutionAttributes executionAttributes)
{
    Instant baselineInstant = Instant.now().truncatedTo(ChronoUnit.DAYS);

    executionAttributes.putAttribute(AwsSignerExecutionAttribute.PRESIGNER_EXPIRATION,
            baselineInstant.plus(3, ChronoUnit.DAYS));

Here we override the signing clock to simulate a fixed time which ultimately results in consistent expiry and signature in the url until a certain date in future:

    Aws4PresignerParams.Builder builder = Aws4PresignerParams.builder()
            .signingClockOverride(Clock.fixed(baselineInstant, ZoneId.of("UTC")));

    Aws4PresignerParams signingParams =
            extractPresignerParams(builder, executionAttributes).build();

    return awsSigner.presign(request, signingParams);
    }
}

More details are available here:

https://murf.ai/resources/creating-cache-friendly-presigned-s3-urls-using-v4signer-q1bbqgk

Aragorn
  • 31
  • 3
  • extractPresignerParams is not exposed. So the bottom part is not going to compile correctly. Same issue with the article. – Archimedes Trajano Feb 13 '21 at 13:21
  • As mentioned in the article under CustomAwsS3V4Signer.java /* only relevant portions of these class are mentioned here. You'll need to copy over a few more methods from AwsS3V4Signer. here */ ... More precisely 2 overloaded extractPresignerParams() from AwsS3V4Signer because it is unfortunately declared final. – Aragorn Mar 15 '21 at 18:28
  • I think there's a way around that because I recall doing it differently but I did use your answer as a starting point. – Archimedes Trajano Mar 15 '21 at 20:20
0

Rather than using the presigned URL mechanism perhaps you could add an authenticated endpoint to your application and within said endpoint retrieve the image? Using this URL in your img tags and such. This endpoint could cache the image and provide the appropriate response headers for the browser to cache the image too.

adeslade
  • 35
  • 3
  • 12
    Thanks, that was my first idea but then I lose the benefit of bypassing my server completely for assets. – ezero Jul 20 '17 at 13:27
0

Similar to the concept of @Aragorn but this is more complete code. This is Java again though. Also since my app is multi-region I had to put in the region properties.

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
import software.amazon.awssdk.core.signer.Signer;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;

import javax.annotation.PostConstruct;
import javax.validation.constraints.NotNull;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;

@Component
@Slf4j
public class S3Operations {

    @Autowired
    private Signer awsSigner;

    private final Map<Region, S3Presigner> presignerMap = new ConcurrentHashMap<>();

    private S3Presigner buildPresignerForRegion(
      AwsCredentialsProvider credentialsProvider,
      Region region) {

        return S3Presigner.builder()
            .credentialsProvider(credentialsProvider)
            .region(region)
            .build();

    }


    /**
     * Convert an S3 URI to a normal HTTPS URI that expires.
     *
     * @param s3Uri S3 URI (e.g. s3://bucketname/ArchieTest/フェニックス.jpg)
     * @return https URI
     */
    @SneakyThrows
    public URI getExpiringUri(final URI s3Uri) {

        final GetObjectRequest getObjectRequest =
            GetObjectRequest.builder()
                .bucket(s3Uri.getHost())
                .key(s3Uri.getPath().substring(1))
                .overrideConfiguration(builder -> builder.signer(awsSigner))
                .build();

        final Region bucketRegion = bucketRegionMap.computeIfAbsent(s3Uri.getHost(),
            bucketName -> {
                final GetBucketLocationRequest getBucketLocationRequest = GetBucketLocationRequest.builder()
                    .bucket(bucketName)
                    .build();

                return Region.of(s3Client.getBucketLocation(getBucketLocationRequest).locationConstraint().toString());
            });

        final GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder()
            .signatureDuration(Duration.ofSeconds(0)) // required, but ignored
            .getObjectRequest(getObjectRequest)
            .build();

        return presignerMap.computeIfAbsent(bucketRegion, this::buildPresignerForRegion).presignGetObject(getObjectPresignRequest).url().toURI();

    }

For CustomAwsSigner which is injected above. The key difference being I throw an unsupported operation exception.

import org.jetbrains.annotations.TestOnly;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.auth.signer.AwsS3V4Signer;
import software.amazon.awssdk.auth.signer.AwsSignerExecutionAttribute;
import software.amazon.awssdk.auth.signer.params.Aws4PresignerParams;
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
import software.amazon.awssdk.core.signer.Presigner;
import software.amazon.awssdk.core.signer.Signer;
import software.amazon.awssdk.http.SdkHttpFullRequest;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;

/**
 * This is a custom signer where the expiration is preset to a 5 minute block within an hour.
 * This must only be used for presigning.
 */
@Component
public class CustomAwsSigner implements Signer, Presigner {
    private final AwsS3V4Signer theSigner = AwsS3V4Signer.create();

    /**
     * This is the clip time for the expiration.  This should be divisible into 60.
     */
    @Value("${aws.s3.clipTimeInMinutes:5}")
    private long clipTimeInMinutes;

    @Value("${aws.s3.expirationInSeconds:3600}")
    private long expirationInSeconds;

    /**
     * Computes the base time as the processing time to the floor of nearest clip block.
     *
     * @param processingDateTime processing date time
     * @return base time
     */
    @TestOnly
    public ZonedDateTime computeBaseTime(final ZonedDateTime processingDateTime) {

        return processingDateTime
            .truncatedTo(ChronoUnit.MINUTES)
            .with(temporal -> temporal.with(ChronoField.MINUTE_OF_HOUR, temporal.get(ChronoField.MINUTE_OF_HOUR) / clipTimeInMinutes * clipTimeInMinutes));

    }

    @Override
    public SdkHttpFullRequest presign(final SdkHttpFullRequest request, final ExecutionAttributes executionAttributes) {

        final Instant baselineInstant = computeBaseTime(ZonedDateTime.now()).toInstant();

        final Aws4PresignerParams signingParams = Aws4PresignerParams.builder()
            .awsCredentials(executionAttributes.getAttribute(AwsSignerExecutionAttribute.AWS_CREDENTIALS))
            .signingName(executionAttributes.getAttribute(AwsSignerExecutionAttribute.SERVICE_SIGNING_NAME))
            .signingRegion(executionAttributes.getAttribute(AwsSignerExecutionAttribute.SIGNING_REGION))
            .signingClockOverride(Clock.fixed(baselineInstant, ZoneId.of("UTC")))
            .expirationTime(baselineInstant.plus(expirationInSeconds, ChronoUnit.SECONDS))
            .build();
        return theSigner.presign(request, signingParams);

    }

    @Override
    public SdkHttpFullRequest sign(final SdkHttpFullRequest request, final ExecutionAttributes executionAttributes) {

        throw new UnsupportedOperationException("this class is only used for presigning");

    }
}
Archimedes Trajano
  • 35,625
  • 19
  • 175
  • 265
0

In case anyone is struggling using golang for presigning urls with cache possibilities, you can create a custom sign handler and swap the named handler with your own to change the sign time and make the urls the same for a time bucket:

import (
    "time"

    "github.com/aws/aws-sdk-go/aws/request"
    v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
)

// Will create same url if in the same 15 minutes time bucket
const presignPeriod = 15 * time.Minute

// TimeInterface implements an interface that
// has the a time variable (Now) and a function
// to retrieve the time variable
type TimeInterface struct {
    Now time.Time
}

func (t *TimeInterface) NowFunc() time.Time {
    return t.Now
}

// getSignTime function returns the signing time
// (initial time) for the time bucket
func getSignTime() time.Time {
    now := time.Now().UTC()
    signTime := now.Round(presignPeriod)
    if signTime.After(now) {
        signTime.Add(-presignPeriod)
    }

    return signTime
}

// CustomSignSDKRequest Implements a custom aws signing
// handler that sets signing time on buckets of 
// <presignPeriod> minutes.
// It is used so browsers can cache the result of the
// url for get requests, instead of downloading the resource everytime.
func CustomSignSDKRequest(req *request.Request) {
    t := TimeInterface{
        Now: getSignTime(),
    }
    v4.SignSDKRequestWithCurrentTime(req, t.NowFunc)
}