diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java b/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java index e9073242..4502aec4 100644 --- a/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java +++ b/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java @@ -392,10 +392,12 @@ public Response putObject(@PathParam("bucket") String bucket, byte[] data = decodeAwsChunked(body, contentEncoding, contentSha256); validateChecksumHeaders(httpHeaders, data); String persistedEncoding = toPersistedContentEncoding(contentEncoding); + String cacheControl = httpHeaders.getHeaderString("Cache-Control"); S3Object obj = s3Service.putObject(bucket, key, data, contentType, extractUserMetadata(httpHeaders), httpHeaders.getHeaderString("x-amz-storage-class"), persistedEncoding, - lockMode, retainUntil, legalHold); + lockMode, retainUntil, legalHold, + cacheControl); var resp = Response.ok().header("ETag", obj.getETag()); if (obj.getVersionId() != null) { resp.header("x-amz-version-id", obj.getVersionId()); @@ -1287,6 +1289,9 @@ private void appendObjectHeaders(Response.ResponseBuilder resp, S3Object obj) { if (obj.getContentEncoding() != null) { resp.header("Content-Encoding", obj.getContentEncoding()); } + if (obj.getCacheControl() != null) { + resp.header("Cache-Control", obj.getCacheControl()); + } if (obj.getMetadata() != null) { for (Map.Entry entry : obj.getMetadata().entrySet()) { resp.header("x-amz-meta-" + entry.getKey(), entry.getValue()); @@ -1331,12 +1336,14 @@ private Response handleCopyObject(String copySource, String destBucket, String d String sourceKey = decodedSource.substring(slashIndex + 1); String copyContentEncoding = toPersistedContentEncoding(httpHeaders.getHeaderString("Content-Encoding")); + String copyCacheControl = httpHeaders.getHeaderString("Cache-Control"); S3Object copy = s3Service.copyObject(sourceBucket, sourceKey, destBucket, destKey, httpHeaders.getHeaderString("x-amz-metadata-directive"), extractUserMetadata(httpHeaders), httpHeaders.getHeaderString("x-amz-storage-class"), contentType, - copyContentEncoding); + copyContentEncoding, + copyCacheControl); String xml = new XmlBuilder() .raw("") .start("CopyObjectResult", AwsNamespaces.S3) diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java b/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java index 6097c102..0c8f6e99 100644 --- a/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java +++ b/src/main/java/io/github/hectorvent/floci/services/s3/S3Service.java @@ -184,29 +184,30 @@ public List listBuckets() { public S3Object putObject(String bucketName, String key, byte[] data, String contentType, Map metadata) { - return putObject(bucketName, key, data, contentType, metadata, null, null, null, null); + return putObject(bucketName, key, data, contentType, metadata, null, null, null, null, null, null); } public S3Object putObject(String bucketName, String key, byte[] data, String contentType, Map metadata, String objectLockMode, Instant retainUntilDate, String legalHoldStatus) { - return putObject(bucketName, key, data, contentType, metadata, null, - objectLockMode, retainUntilDate, legalHoldStatus); + return putObject(bucketName, key, data, contentType, metadata, null, null, + objectLockMode, retainUntilDate, legalHoldStatus, null); } public S3Object putObject(String bucketName, String key, byte[] data, String contentType, Map metadata, String storageClass, String objectLockMode, Instant retainUntilDate, String legalHoldStatus) { return putObject(bucketName, key, data, contentType, metadata, storageClass, null, - objectLockMode, retainUntilDate, legalHoldStatus); + objectLockMode, retainUntilDate, legalHoldStatus, null); } public S3Object putObject(String bucketName, String key, byte[] data, String contentType, Map metadata, String storageClass, String contentEncoding, - String objectLockMode, Instant retainUntilDate, String legalHoldStatus) { + String objectLockMode, Instant retainUntilDate, String legalHoldStatus, + String cacheControl) { S3Object object = storeObject(bucketName, key, data, contentType, metadata, storageClass, null, null, - objectLockMode, retainUntilDate, legalHoldStatus, contentEncoding); + objectLockMode, retainUntilDate, legalHoldStatus, contentEncoding, cacheControl); fireNotifications(bucketName, key, "ObjectCreated:Put", object); return object; } @@ -217,7 +218,7 @@ public S3Object putObject(String bucketName, String key, byte[] data, private S3Object storeObject(String bucketName, String key, byte[] data, String contentType, Map metadata) { return storeObject(bucketName, key, data, contentType, metadata, null, null, null, - null, null, null, null); + null, null, null, null, null); } private S3Object storeObject(String bucketName, String key, byte[] data, @@ -225,14 +226,14 @@ private S3Object storeObject(String bucketName, String key, byte[] data, S3Checksum checksum, List parts, String objectLockMode, Instant retainUntilDate, String legalHoldStatus) { return storeObject(bucketName, key, data, contentType, metadata, storageClass, checksum, parts, - objectLockMode, retainUntilDate, legalHoldStatus, null); + objectLockMode, retainUntilDate, legalHoldStatus, null, null); } private S3Object storeObject(String bucketName, String key, byte[] data, String contentType, Map metadata, String storageClass, S3Checksum checksum, List parts, String objectLockMode, Instant retainUntilDate, String legalHoldStatus, - String contentEncoding) { + String contentEncoding, String cacheControl) { Bucket bucket = bucketStore.get(bucketName) .orElseThrow(() -> new AwsException("NoSuchBucket", "The specified bucket does not exist.", 404)); @@ -245,6 +246,7 @@ private S3Object storeObject(String bucketName, String key, byte[] data, object.setChecksum(checksum != null ? copyChecksum(checksum) : buildChecksum(data, parts, false)); object.setParts(copyParts(parts)); object.setContentEncoding(contentEncoding); + object.setCacheControl(cacheControl); if (bucket.isVersioningEnabled()) { String versionId = UUID.randomUUID().toString(); @@ -597,13 +599,14 @@ public S3Object copyObject(String sourceBucket, String sourceKey, String metadataDirective, Map replacementMetadata, String storageClass, String contentType) { return copyObject(sourceBucket, sourceKey, destBucket, destKey, metadataDirective, - replacementMetadata, storageClass, contentType, null); + replacementMetadata, storageClass, contentType, null, null); } public S3Object copyObject(String sourceBucket, String sourceKey, String destBucket, String destKey, String metadataDirective, Map replacementMetadata, - String storageClass, String contentType, String contentEncoding) { + String storageClass, String contentType, String contentEncoding, + String cacheControl) { S3Object source = getObject(sourceBucket, sourceKey); ensureBucketExists(destBucket); @@ -616,9 +619,10 @@ public S3Object copyObject(String sourceBucket, String sourceKey, String effectiveContentType = replaceMetadata && contentType != null ? contentType : source.getContentType(); String effectiveStorageClass = storageClass != null ? storageClass : source.getStorageClass(); String effectiveContentEncoding = replaceMetadata && contentEncoding != null ? contentEncoding : source.getContentEncoding(); + String effectiveCacheControl = replaceMetadata && cacheControl != null ? cacheControl : source.getCacheControl(); S3Object copy = storeObject(destBucket, destKey, source.getData(), effectiveContentType, metadata, effectiveStorageClass, source.getChecksum(), source.getParts(), null, null, null, - effectiveContentEncoding); + effectiveContentEncoding, effectiveCacheControl); copy.setETag(source.getETag()); LOG.debugv("Copied object: {0}/{1} -> {2}/{3}", sourceBucket, sourceKey, destBucket, destKey); fireNotifications(destBucket, destKey, "ObjectCreated:Copy", copy); @@ -1560,6 +1564,7 @@ private static S3Object copyObject(S3Object source) { copy.setMetadata(new HashMap<>(source.getMetadata())); copy.setContentType(source.getContentType()); copy.setContentEncoding(source.getContentEncoding()); + copy.setCacheControl(source.getCacheControl()); copy.setSize(source.getSize()); copy.setLastModified(source.getLastModified()); copy.setETag(source.getETag()); diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/model/S3Object.java b/src/main/java/io/github/hectorvent/floci/services/s3/model/S3Object.java index facd71ec..e39abaf2 100644 --- a/src/main/java/io/github/hectorvent/floci/services/s3/model/S3Object.java +++ b/src/main/java/io/github/hectorvent/floci/services/s3/model/S3Object.java @@ -22,6 +22,7 @@ public class S3Object { private Map metadata; private String contentType; private String contentEncoding; + private String cacheControl; private long size; private Instant lastModified; private String eTag; @@ -81,6 +82,9 @@ public S3Object(String bucketName, String key, byte[] data, String contentType) public String getContentEncoding() { return contentEncoding; } public void setContentEncoding(String contentEncoding) { this.contentEncoding = contentEncoding; } + public String getCacheControl() { return cacheControl; } + public void setCacheControl(String cacheControl) { this.cacheControl = cacheControl; } + public long getSize() { return size; } public void setSize(long size) { this.size = size; } diff --git a/src/test/java/io/github/hectorvent/floci/services/s3/S3IntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/s3/S3IntegrationTest.java index 865f65e5..313d668d 100644 --- a/src/test/java/io/github/hectorvent/floci/services/s3/S3IntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/s3/S3IntegrationTest.java @@ -898,6 +898,98 @@ void cleanupContentEncodingBucket() { given().delete("/encoding-test-bucket"); } + // --- Cache-Control header preservation --- + + @Test + @Order(89) + void createCacheControlBucketAndPutObject() { + given() + .put("/cache-control-bucket") + .then() + .statusCode(200); + + given() + .contentType("text/plain") + .header("Cache-Control", "public, max-age=31536000") + .body("cached-content") + .when() + .put("/cache-control-bucket/cached.txt") + .then() + .statusCode(200) + .header("ETag", notNullValue()); + } + + @Test + @Order(90) + void getObjectReturnsCacheControl() { + given() + .when() + .get("/cache-control-bucket/cached.txt") + .then() + .statusCode(200) + .header("Cache-Control", equalTo("public, max-age=31536000")); + } + + @Test + @Order(90) + void headObjectReturnsCacheControl() { + given() + .when() + .head("/cache-control-bucket/cached.txt") + .then() + .statusCode(200) + .header("Cache-Control", equalTo("public, max-age=31536000")); + } + + @Test + @Order(91) + void copyObjectPreservesCacheControl() { + given() + .header("x-amz-copy-source", "/cache-control-bucket/cached.txt") + .when() + .put("/cache-control-bucket/cached-copy.txt") + .then() + .statusCode(200) + .body(containsString("CopyObjectResult")); + + given() + .when() + .head("/cache-control-bucket/cached-copy.txt") + .then() + .statusCode(200) + .header("Cache-Control", equalTo("public, max-age=31536000")); + } + + @Test + @Order(91) + void copyObjectReplaceCacheControl() { + given() + .header("x-amz-copy-source", "/cache-control-bucket/cached.txt") + .header("x-amz-metadata-directive", "REPLACE") + .header("Cache-Control", "no-cache") + .when() + .put("/cache-control-bucket/cached-nocache.txt") + .then() + .statusCode(200) + .body(containsString("CopyObjectResult")); + + given() + .when() + .head("/cache-control-bucket/cached-nocache.txt") + .then() + .statusCode(200) + .header("Cache-Control", equalTo("no-cache")); + } + + @Test + @Order(92) + void cleanupCacheControlBucket() { + given().delete("/cache-control-bucket/cached.txt"); + given().delete("/cache-control-bucket/cached-copy.txt"); + given().delete("/cache-control-bucket/cached-nocache.txt"); + given().delete("/cache-control-bucket"); + } + // --- S3 Notification Configuration with Filter --- @Test