diff --git a/docker-compose.yml b/docker-compose.yml index 46f317e..405f0b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,27 @@ services: timeout: 10s retries: 15 + localstack: + image: localstack/localstack:3.8 + container_name: shopsphere-localstack + ports: + # S3 (and other AWS APIs) on the edge port. Presigned URLs are signed against + # HOSTNAME_EXTERNAL=localhost so a link opened in your browser actually resolves. + - "4566:4566" + environment: + SERVICES: s3 + # Enforce presigned-URL signatures/expiry (LocalStack skips this by default), matching + # real S3 and the test setup in SharedContainers. + S3_SKIP_SIGNATURE_VALIDATION: "0" + volumes: + # Creates the product-image bucket on startup (see scripts/localstack-init.sh). + - ./scripts/localstack-init.sh:/etc/localstack/init/ready.d/init.sh + healthcheck: + test: ["CMD-SHELL", "awslocal s3 ls s3://shopsphere-product-images >/dev/null 2>&1 || exit 1"] + interval: 5s + timeout: 5s + retries: 15 + app: # Only started with `docker compose --profile full up -d --build`. profiles: ["full"] @@ -81,6 +102,8 @@ services: condition: service_healthy kafka: condition: service_healthy + localstack: + condition: service_healthy environment: DB_HOST: postgres DB_PORT: "5432" @@ -89,6 +112,16 @@ services: DB_PASSWORD: shopsphere KAFKA_BOOTSTRAP_SERVERS: kafka:29092 JWT_SECRET: dev-only-secret-change-me-32-bytes-minimum-aaaa + # Storage seam: the containerised app reaches LocalStack by service name. NOTE: presigned URLs + # are signed against this endpoint, so under the `full` profile they read `localstack:4566` — + # resolvable inside the compose network but not from a host browser. The primary dev loop + # (app on the host via `mvn spring-boot:run`, S3_ENDPOINT defaulting to localhost:4566) mints + # host-openable URLs. Real S3 has no such split. See ADR-0016. + S3_ENDPOINT: http://localstack:4566 + S3_BUCKET: shopsphere-product-images + AWS_REGION: us-east-1 + S3_ACCESS_KEY: test + S3_SECRET_KEY: test ports: - "8080:8080" diff --git a/docs/adr/0016-product-images-on-s3-with-localstack-parity.md b/docs/adr/0016-product-images-on-s3-with-localstack-parity.md new file mode 100644 index 0000000..9efa4bb --- /dev/null +++ b/docs/adr/0016-product-images-on-s3-with-localstack-parity.md @@ -0,0 +1,33 @@ +--- +status: accepted +date: 2026-06-05 +cites: APoSD, PoEAA, DDD, XP, PragProg +--- + +# 0016 — Product images on S3, behind a deep module, with LocalStack for dev/cloud parity + +Phase 16 lets an admin attach an image to a product and lets shoppers see it. The bytes live in S3; the database stores only a key; reads are served by short-lived presigned URLs from a private bucket. The design goal that shaped everything was operability: **spin the project up locally and point the same binary at real S3 by changing configuration, not code** — the S3 mirror of the Phase-11 RDS seam. + +## A deep module over storage + +`ProductImageStorage` exposes two operations — `upload(productId, bytes, contentType) → key` and `presignedRead(key, ttl) → URL` — and hides everything else: the S3 SDK, the client, the bucket, the `.` key scheme, the content-type allow-list, and how a URL is signed. **APoSD** — this is a deep module in the same family as Phase-14's `PaymentMethods` and Phase-15's `PaymentProvider`: a tiny interface over a substantial concern. Catalog code (the controllers, the `ProductMapper`) never imports an S3 type; swapping the backend touches no caller. **DDD** — images belong to Products, so the module and the `image_key` column live in the Catalog context; no other module learns that S3 exists. + +## The dev↔cloud seam, and LocalStack as a high-fidelity Service Stub + +The one knob that flips environments is `shopsphere.storage.s3.endpoint`: a LocalStack URL in dev/test, blank in cloud so the SDK resolves real S3. Credentials follow the same shape — explicit keys for LocalStack, otherwise the default AWS provider chain (instance profile). Path-style addressing is forced so the same configuration works against both. **PragProg — configure, don't hardcode**: the bucket, region, endpoint, and credentials are all configuration; the same JAR serves dev, the fully-containerised `full` compose profile, and cloud. + +For testing, the choice was a hand-rolled in-memory fake versus a real S3 API. **PoEAA's Service Stub** says test the whole system against a stand-in — and LocalStack is a *high-fidelity* stand-in: it speaks the real S3 protocol, so the same `putObject`/presign code runs in tests and in production, including signature and expiry behaviour. That fidelity caught a real subtlety: LocalStack **skips presigned-URL signature validation by default**, which would have let an expired URL keep working and made the expiry test a no-op. Turning validation on (`S3_SKIP_SIGNATURE_VALIDATION=0`) makes the stub behave like S3 — the expired-URL-returns-403 test is meaningful precisely because the stub now enforces what S3 enforces. A fake would never have surfaced that. + +## Private bucket, presigned reads + +The bucket is private — public access blocked at every lever, ACLs disabled, SSE-S3 at rest (the authored, deferred Terraform). Clients never address the bucket directly; `ProductMapper` mints a **5-minute presigned read URL** per product on `GET /products` (null when the product has no image). Presigning is a *local* signature computation — no S3 round-trip — so minting one per row while paging a product list stays cheap. **PoEAA** — the private bucket plus a short-lived signed URL is the standard "don't make storage public to serve it" pattern; the URL is a capability, time-boxed. + +## What's deliberately deferred or limited + +- **Cloud Terraform is authored, not applied** (`terraform/s3/`), exactly like the secrets work (ADR-0013). The dev/test path is fully LocalStack, so the real bucket is only needed on real AWS; `terraform apply` is a future lab step and the app does not depend on it. **XP YAGNI** — no bucket provisioned for a cloud run that isn't a current goal. +- **Presigned-URL host under the fully-containerised `full` profile.** A presigned URL is signed against the client's endpoint, so when the *app itself* runs inside compose (`S3_ENDPOINT=http://localstack:4566`) the minted URLs read `localstack:4566` — resolvable inside the compose network but not from a host browser. The primary dev loop (app on the host, endpoint `localhost:4566`) mints host-openable URLs, and real S3 has no such split. Splitting the client and presigner endpoints would fix the `full`-profile browser case; it is not worth the extra config knob today (**APoSD** — don't add complexity a real need hasn't asked for). Recorded so it is a known limitation, not a surprise. +- **No image processing.** No resizing, thumbnails, or CDN — upload and presigned read only (**XP YAGNI**). The seam makes adding them later a change behind the interface. + +## Consequences + +Product images work end-to-end with **zero external dependency**: `docker compose up` brings up LocalStack with the bucket created, and `mvn verify` runs the image tests against LocalStack via Testcontainers — no AWS account, no credential. The admin upload endpoint also lands the `hasRole('ADMIN')` guard that ADR-0017 deferred for exactly this endpoint. The reversibility seam (PragProg) means the move to real S3 is a configuration change plus a `terraform apply`, with the IAM policy scoped to `GetObject`/`PutObject` on the one bucket. diff --git a/docs/modulith/components.puml b/docs/modulith/components.puml index 8acff6d..f4e1f75 100644 --- a/docs/modulith/components.puml +++ b/docs/modulith/components.puml @@ -16,12 +16,12 @@ Container_Boundary("ShopSphere.ShopSphere_boundary", "ShopSphere", $tags="") { Component(ShopSphere.ShopSphere.Ordering, "Ordering", $techn="Module", $descr="", $tags="", $link="") } +Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Catalog, "uses", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Payment, "uses", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Payment, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Catalog, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Identity, "depends on", $techn="", $tags="", $link="") -Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") SHOW_LEGEND(true) @enduml \ No newline at end of file diff --git a/docs/modulith/module-ordering.puml b/docs/modulith/module-ordering.puml index d37d337..50bc7d6 100644 --- a/docs/modulith/module-ordering.puml +++ b/docs/modulith/module-ordering.puml @@ -16,12 +16,12 @@ Container_Boundary("ShopSphere.ShopSphere_boundary", "ShopSphere", $tags="") { Component(ShopSphere.ShopSphere.Ordering, "Ordering", $techn="Module", $descr="", $tags="", $link="") } +Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Catalog, "uses", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Payment, "uses", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Payment, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Catalog, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Identity, "depends on", $techn="", $tags="", $link="") -Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") SHOW_LEGEND(true) @enduml \ No newline at end of file diff --git a/pom.xml b/pom.xml index 58102ba..a792545 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,7 @@ 1.20.3 1.2.5 0.12.6 + 2.28.16 @@ -95,6 +96,11 @@ spring-kafka + + software.amazon.awssdk + s3 + + org.springframework.boot spring-boot-starter-security @@ -157,6 +163,11 @@ kafka test + + org.testcontainers + localstack + test + org.springframework.kafka spring-kafka-test @@ -180,6 +191,13 @@ pom import + + software.amazon.awssdk + bom + ${awssdk.version} + pom + import + diff --git a/scripts/localstack-init.sh b/scripts/localstack-init.sh new file mode 100644 index 0000000..f8f4850 --- /dev/null +++ b/scripts/localstack-init.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Runs inside the LocalStack container once S3 is ready (mounted into init/ready.d). +# Creates the product-image bucket so the app finds it on first boot — the docker-compose +# equivalent of the bucket creation SharedContainers does for tests. +set -e +awslocal s3 mb s3://shopsphere-product-images +echo "localstack-init: created bucket shopsphere-product-images" diff --git a/src/main/java/com/shopsphere/catalog/AdminProductController.java b/src/main/java/com/shopsphere/catalog/AdminProductController.java index 6fac6c5..0ace914 100644 --- a/src/main/java/com/shopsphere/catalog/AdminProductController.java +++ b/src/main/java/com/shopsphere/catalog/AdminProductController.java @@ -10,14 +10,18 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.UUID; /** @@ -32,9 +36,13 @@ class AdminProductController { private final ProductRepository products; + private final ProductImageStorage images; + private final ProductMapper mapper; - AdminProductController(ProductRepository products) { + AdminProductController(ProductRepository products, ProductImageStorage images, ProductMapper mapper) { this.products = products; + this.images = images; + this.mapper = mapper; } @PostMapping @@ -47,7 +55,7 @@ ProductDto create(@Valid @RequestBody ProductRequest request) { request.unitPrice().amount(), request.unitPrice().currency(), request.availableQty()); - return ProductMapper.toDto(products.save(product)); + return mapper.toDto(products.save(product)); } @PutMapping("/{id}") @@ -61,7 +69,9 @@ ResponseEntity update(@PathVariable UUID id, @Valid @RequestBody Pro request.unitPrice().amount(), request.unitPrice().currency(), request.availableQty()); - return ResponseEntity.ok(ProductMapper.toDto(products.save(replacement))); + // An edit replaces the product's fields but not its image — carry the key across. + replacement.setImageKey(existing.getImageKey()); + return ResponseEntity.ok(mapper.toDto(products.save(replacement))); }) .orElseGet(() -> ResponseEntity.notFound().build()); } @@ -75,6 +85,28 @@ ResponseEntity delete(@PathVariable UUID id) { return ResponseEntity.noContent().build(); } + /** + * Uploads (or replaces) the image for a product. The bytes go to S3 via {@link ProductImageStorage}; + * only the returned key is recorded on the product, so the read side can later mint a presigned URL. + * An unsupported content type is rejected 400 (see the exception handler); an unknown product is 404. + */ + @PostMapping("/{id}/image") + ResponseEntity uploadImage(@PathVariable UUID id, @RequestParam("file") MultipartFile file) + throws IOException { + Product product = products.findById(id).orElse(null); + if (product == null) { + return ResponseEntity.notFound().build(); + } + String key = images.upload(id, file.getBytes(), file.getContentType()); + product.setImageKey(key); + return ResponseEntity.ok(mapper.toDto(products.save(product))); + } + + @ExceptionHandler(S3ProductImageStorage.UnsupportedImageTypeException.class) + ResponseEntity unsupportedImageType() { + return ResponseEntity.badRequest().build(); + } + record ProductRequest( @NotBlank String name, @NotBlank String description, diff --git a/src/main/java/com/shopsphere/catalog/Product.java b/src/main/java/com/shopsphere/catalog/Product.java index f1a2885..d2b91d5 100644 --- a/src/main/java/com/shopsphere/catalog/Product.java +++ b/src/main/java/com/shopsphere/catalog/Product.java @@ -31,6 +31,10 @@ class Product { @Column(name = "available_qty", nullable = false) private int availableQty; + // S3 key of this product's uploaded image, or null if none (Phase 16). The bytes live in S3. + @Column(name = "image_key") + private String imageKey; + protected Product() { } @@ -68,6 +72,14 @@ int getAvailableQty() { return availableQty; } + String getImageKey() { + return imageKey; + } + + void setImageKey(String imageKey) { + this.imageKey = imageKey; + } + void decreaseAvailable(int amount) { if (amount <= 0) { throw new IllegalArgumentException("decrement must be positive, got " + amount); diff --git a/src/main/java/com/shopsphere/catalog/ProductController.java b/src/main/java/com/shopsphere/catalog/ProductController.java index b3adb4c..a993eb8 100644 --- a/src/main/java/com/shopsphere/catalog/ProductController.java +++ b/src/main/java/com/shopsphere/catalog/ProductController.java @@ -17,9 +17,11 @@ class ProductController { private final ProductRepository products; + private final ProductMapper mapper; - ProductController(ProductRepository products) { + ProductController(ProductRepository products, ProductMapper mapper) { this.products = products; + this.mapper = mapper; } /** @@ -30,13 +32,13 @@ class ProductController { */ @GetMapping PagedResponse list(@PageableDefault(size = 20, sort = "name") Pageable pageable) { - return PagedResponse.of(products.findAll(pageable), ProductMapper::toDto); + return PagedResponse.of(products.findAll(pageable), mapper::toDto); } @GetMapping("/{id}") ResponseEntity getOne(@PathVariable UUID id) { return products.findById(id) - .map(ProductMapper::toDto) + .map(mapper::toDto) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } diff --git a/src/main/java/com/shopsphere/catalog/ProductDto.java b/src/main/java/com/shopsphere/catalog/ProductDto.java index d61d736..9523fb0 100644 --- a/src/main/java/com/shopsphere/catalog/ProductDto.java +++ b/src/main/java/com/shopsphere/catalog/ProductDto.java @@ -9,6 +9,7 @@ record ProductDto( String name, String description, Money unitPrice, - int availableQty + int availableQty, + String imageUrl ) { } diff --git a/src/main/java/com/shopsphere/catalog/ProductImageStorage.java b/src/main/java/com/shopsphere/catalog/ProductImageStorage.java new file mode 100644 index 0000000..3218102 --- /dev/null +++ b/src/main/java/com/shopsphere/catalog/ProductImageStorage.java @@ -0,0 +1,18 @@ +package com.shopsphere.catalog; + +import java.time.Duration; +import java.util.UUID; + +/** + * Deep module over product-image object storage. Catalog code depends only on these two operations + * and never sees the S3 SDK, the bucket, the key scheme, or how a URL is signed. The implementation + * is swappable by configuration (LocalStack in dev, real S3 in cloud) — see {@link StorageConfig}. + */ +interface ProductImageStorage { + + /** Stores {@code bytes} for {@code productId} and returns the storage key. */ + String upload(UUID productId, byte[] bytes, String contentType); + + /** Mints a time-limited read URL for a stored key. The URL stops working once {@code ttl} elapses. */ + String presignedRead(String key, Duration ttl); +} diff --git a/src/main/java/com/shopsphere/catalog/ProductMapper.java b/src/main/java/com/shopsphere/catalog/ProductMapper.java index e189d9a..9244934 100644 --- a/src/main/java/com/shopsphere/catalog/ProductMapper.java +++ b/src/main/java/com/shopsphere/catalog/ProductMapper.java @@ -1,17 +1,36 @@ package com.shopsphere.catalog; -final class ProductMapper { +import org.springframework.stereotype.Component; - private ProductMapper() { +import java.time.Duration; + +/** + * Maps a {@link Product} to its API DTO, minting a short-lived presigned read URL for the image when + * the product has one (null otherwise). Presigning is a local signature computation — no S3 round-trip + * — so doing it per row while paging a product list is cheap. The bucket itself stays private; the + * 5-minute URL is the only way a client reaches the bytes. + */ +@Component +class ProductMapper { + + private static final Duration IMAGE_URL_TTL = Duration.ofMinutes(5); + + private final ProductImageStorage images; + + ProductMapper(ProductImageStorage images) { + this.images = images; } - static ProductDto toDto(Product product) { + ProductDto toDto(Product product) { + String imageUrl = product.getImageKey() == null + ? null + : images.presignedRead(product.getImageKey(), IMAGE_URL_TTL); return new ProductDto( product.getId(), product.getName(), product.getDescription(), product.getUnitPrice(), - product.getAvailableQty() - ); + product.getAvailableQty(), + imageUrl); } } diff --git a/src/main/java/com/shopsphere/catalog/S3ProductImageStorage.java b/src/main/java/com/shopsphere/catalog/S3ProductImageStorage.java new file mode 100644 index 0000000..2b6658f --- /dev/null +++ b/src/main/java/com/shopsphere/catalog/S3ProductImageStorage.java @@ -0,0 +1,66 @@ +package com.shopsphere.catalog; + +import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.sync.RequestBody; +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 java.time.Duration; +import java.util.UUID; + +/** + * S3 implementation of {@link ProductImageStorage}. Objects are keyed {@code .}, where + * the extension comes from a small allow-list of image content types — an unknown type is rejected + * rather than stored. Reads are served by presigned URLs so the bucket itself stays private. + */ +@Component +class S3ProductImageStorage implements ProductImageStorage { + + private final S3Client s3; + private final S3Presigner presigner; + private final String bucket; + + S3ProductImageStorage(S3Client s3, S3Presigner presigner, S3StorageProperties props) { + this.s3 = s3; + this.presigner = presigner; + this.bucket = props.bucket(); + } + + @Override + public String upload(UUID productId, byte[] bytes, String contentType) { + String key = productId + "." + extensionFor(contentType); + s3.putObject( + PutObjectRequest.builder().bucket(bucket).key(key).contentType(contentType).build(), + RequestBody.fromBytes(bytes)); + return key; + } + + @Override + public String presignedRead(String key, Duration ttl) { + GetObjectRequest get = GetObjectRequest.builder().bucket(bucket).key(key).build(); + return presigner.presignGetObject(GetObjectPresignRequest.builder() + .signatureDuration(ttl) + .getObjectRequest(get) + .build()) + .url() + .toString(); + } + + private static String extensionFor(String contentType) { + return switch (contentType == null ? "" : contentType) { + case "image/png" -> "png"; + case "image/jpeg" -> "jpg"; + case "image/webp" -> "webp"; + default -> throw new UnsupportedImageTypeException(contentType); + }; + } + + static final class UnsupportedImageTypeException extends RuntimeException { + UnsupportedImageTypeException(String contentType) { + super("unsupported image content-type: " + contentType); + } + } +} diff --git a/src/main/java/com/shopsphere/catalog/S3StorageProperties.java b/src/main/java/com/shopsphere/catalog/S3StorageProperties.java new file mode 100644 index 0000000..5cabe8b --- /dev/null +++ b/src/main/java/com/shopsphere/catalog/S3StorageProperties.java @@ -0,0 +1,13 @@ +package com.shopsphere.catalog; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration seam for product-image storage. The single knob that flips dev↔cloud is + * {@code endpoint}: set to a LocalStack URL in dev, left blank in cloud so the SDK resolves real S3. + * Credentials follow the same idea — explicit keys for LocalStack, otherwise the default AWS provider + * chain (instance profile). The same binary serves both; only configuration changes. + */ +@ConfigurationProperties(prefix = "shopsphere.storage.s3") +record S3StorageProperties(String endpoint, String bucket, String region, String accessKey, String secretKey) { +} diff --git a/src/main/java/com/shopsphere/catalog/StorageConfig.java b/src/main/java/com/shopsphere/catalog/StorageConfig.java new file mode 100644 index 0000000..d65b83a --- /dev/null +++ b/src/main/java/com/shopsphere/catalog/StorageConfig.java @@ -0,0 +1,58 @@ +package com.shopsphere.catalog; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +/** + * Builds the S3 client and presigner from {@link S3StorageProperties}. Path-style access is forced so + * the same configuration works against LocalStack (which is not virtual-host addressable) and real S3. + * When {@code endpoint} is blank the SDK uses its normal AWS endpoint resolution — the dev↔cloud flip. + */ +@Configuration +@EnableConfigurationProperties(S3StorageProperties.class) +class StorageConfig { + + @Bean + S3Client s3Client(S3StorageProperties props) { + var builder = S3Client.builder() + .region(Region.of(props.region())) + .forcePathStyle(true) + .credentialsProvider(credentials(props)); + if (StringUtils.hasText(props.endpoint())) { + builder.endpointOverride(URI.create(props.endpoint())); + } + return builder.build(); + } + + @Bean + S3Presigner s3Presigner(S3StorageProperties props) { + var builder = S3Presigner.builder() + .region(Region.of(props.region())) + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .credentialsProvider(credentials(props)); + if (StringUtils.hasText(props.endpoint())) { + builder.endpointOverride(URI.create(props.endpoint())); + } + return builder.build(); + } + + private static AwsCredentialsProvider credentials(S3StorageProperties props) { + if (StringUtils.hasText(props.accessKey()) && StringUtils.hasText(props.secretKey())) { + return StaticCredentialsProvider.create( + AwsBasicCredentials.create(props.accessKey(), props.secretKey())); + } + return DefaultCredentialsProvider.create(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bf0f813..152da40 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,6 +69,16 @@ shopsphere: ttl: PT15M refresh: ttl: P7D + storage: + s3: + # The dev↔cloud seam (mirrors the ${DB_HOST} pattern): LocalStack endpoint by default; set + # S3_ENDPOINT empty in cloud so the SDK resolves real S3. Credentials are blank here so the + # default AWS provider chain (instance profile) is used unless S3_ACCESS_KEY/S3_SECRET_KEY set. + endpoint: ${S3_ENDPOINT:http://localhost:4566} + bucket: ${S3_BUCKET:shopsphere-product-images} + region: ${AWS_REGION:us-east-1} + access-key: ${S3_ACCESS_KEY:} + secret-key: ${S3_SECRET_KEY:} ordering: idempotency: # Delete idempotency claims older than this. Must outlast any legitimate client-retry window; diff --git a/src/main/resources/db/migration/catalog/V15__product_image_key.sql b/src/main/resources/db/migration/catalog/V15__product_image_key.sql new file mode 100644 index 0000000..2097211 --- /dev/null +++ b/src/main/resources/db/migration/catalog/V15__product_image_key.sql @@ -0,0 +1,4 @@ +-- Phase 16: a product may have one uploaded image, stored in S3 under this key (e.g. .png). +-- Nullable: most products have no image. The bytes live in S3, not the database — this column only +-- records where to find them, so GET /products can mint a presigned read URL without an existence check. +ALTER TABLE catalog.products ADD COLUMN image_key VARCHAR(255); diff --git a/src/test/java/com/shopsphere/SharedContainers.java b/src/test/java/com/shopsphere/SharedContainers.java index e69f42c..66e1b6a 100644 --- a/src/test/java/com/shopsphere/SharedContainers.java +++ b/src/test/java/com/shopsphere/SharedContainers.java @@ -3,7 +3,13 @@ import org.springframework.test.context.DynamicPropertyRegistry; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.localstack.LocalStackContainer; import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; /** * Singleton Postgres + Kafka containers shared across every {@code @SpringBootTest}. Starting fresh @@ -20,12 +26,34 @@ public final class SharedContainers { private SharedContainers() { } + private static final String IMAGE_BUCKET = "shopsphere-product-images"; + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16"); static final KafkaContainer KAFKA = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1")); + static final LocalStackContainer LOCALSTACK = new LocalStackContainer( + DockerImageName.parse("localstack/localstack:3.8")) + .withServices(LocalStackContainer.Service.S3) + // Enforce presigned-URL signatures/expiry — LocalStack skips this by default, which would + // let an expired URL still resolve and make the expiry test meaningless (and unlike real S3). + .withEnv("S3_SKIP_SIGNATURE_VALIDATION", "0"); static { POSTGRES.start(); KAFKA.start(); + LOCALSTACK.start(); + createImageBucket(); + } + + private static void createImageBucket() { + try (S3Client s3 = S3Client.builder() + .endpointOverride(LOCALSTACK.getEndpoint()) + .region(Region.of(LOCALSTACK.getRegion())) + .forcePathStyle(true) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(LOCALSTACK.getAccessKey(), LOCALSTACK.getSecretKey()))) + .build()) { + s3.createBucket(CreateBucketRequest.builder().bucket(IMAGE_BUCKET).build()); + } } public static void registerProperties(DynamicPropertyRegistry registry) { @@ -33,6 +61,11 @@ public static void registerProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.username", POSTGRES::getUsername); registry.add("spring.datasource.password", POSTGRES::getPassword); registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); + registry.add("shopsphere.storage.s3.endpoint", () -> LOCALSTACK.getEndpoint().toString()); + registry.add("shopsphere.storage.s3.bucket", () -> IMAGE_BUCKET); + registry.add("shopsphere.storage.s3.region", LOCALSTACK::getRegion); + registry.add("shopsphere.storage.s3.access-key", LOCALSTACK::getAccessKey); + registry.add("shopsphere.storage.s3.secret-key", LOCALSTACK::getSecretKey); // Spring caches one context (hence one Hikari pool) per distinct test configuration, and a // RANDOM_PORT IT is its own configuration. Several cached pools at the default size of 10 // each exhaust Postgres's default max_connections (100) — failing late ITs with diff --git a/src/test/java/com/shopsphere/catalog/AdminImageUploadIT.java b/src/test/java/com/shopsphere/catalog/AdminImageUploadIT.java new file mode 100644 index 0000000..8996190 --- /dev/null +++ b/src/test/java/com/shopsphere/catalog/AdminImageUploadIT.java @@ -0,0 +1,159 @@ +package com.shopsphere.catalog; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shopsphere.SharedContainers; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Image upload is admin-only — the same {@code hasRole('ADMIN')} guard as the rest of the admin + * product API (closing the guard deferred in Phase 17 / ADR-0017). Covers USER→403, admin→200, + * an unsupported content type→400, and an unknown product→404. + */ +@SpringBootTest +@AutoConfigureMockMvc +class AdminImageUploadIT { + + @DynamicPropertySource + static void containers(DynamicPropertyRegistry registry) { + SharedContainers.registerProperties(registry); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper json; + + private MockMultipartFile png() { + return new MockMultipartFile("file", "pic.png", "image/png", "fake-png".getBytes()); + } + + @Test + void normalUserCannotUploadImage() throws Exception { + UUID productId = createProduct(); + mockMvc.perform(multipart("/api/v1/admin/products/" + productId + "/image") + .file(png()) + .header("Authorization", "Bearer " + registerAndLogin())) + .andExpect(status().isForbidden()); + } + + @Test + void adminCanUploadImage() throws Exception { + UUID productId = createProduct(); + mockMvc.perform(multipart("/api/v1/admin/products/" + productId + "/image") + .file(png()) + .header("Authorization", "Bearer " + loginAsAdmin())) + .andExpect(status().isOk()); + } + + @Test + void uploadedImageIsServedAsAWorkingPresignedUrlOnProductRead() throws Exception { + String admin = loginAsAdmin(); + UUID productId = createProduct(); + byte[] bytes = "the-real-png-bytes".getBytes(StandardCharsets.UTF_8); + + mockMvc.perform(multipart("/api/v1/admin/products/" + productId + "/image") + .file(new MockMultipartFile("file", "pic.png", "image/png", bytes)) + .header("Authorization", "Bearer " + admin)) + .andExpect(status().isOk()); + + MvcResult read = mockMvc.perform(get("/api/v1/products/" + productId) + .header("Authorization", "Bearer " + admin)) + .andExpect(status().isOk()) + .andReturn(); + String imageUrl = json.readTree(read.getResponse().getContentAsString()).get("imageUrl").asText(); + + // The presigned URL the API handed back must actually resolve to the uploaded bytes. + HttpResponse fetched = HttpClient.newHttpClient().send( + HttpRequest.newBuilder(URI.create(imageUrl)).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + assertThat(fetched.statusCode()).isEqualTo(200); + assertThat(fetched.body()).isEqualTo(bytes); + } + + @Test + void productWithoutImageHasNullImageUrl() throws Exception { + String admin = loginAsAdmin(); + UUID productId = createProduct(); + + mockMvc.perform(get("/api/v1/products/" + productId) + .header("Authorization", "Bearer " + admin)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.imageUrl").value(nullValue())); + } + + @Test + void unsupportedContentTypeIsRejected() throws Exception { + UUID productId = createProduct(); + mockMvc.perform(multipart("/api/v1/admin/products/" + productId + "/image") + .file(new MockMultipartFile("file", "note.txt", "text/plain", "nope".getBytes())) + .header("Authorization", "Bearer " + loginAsAdmin())) + .andExpect(status().isBadRequest()); + } + + @Test + void uploadingToUnknownProductReturns404() throws Exception { + mockMvc.perform(multipart("/api/v1/admin/products/" + UUID.randomUUID() + "/image") + .file(png()) + .header("Authorization", "Bearer " + loginAsAdmin())) + .andExpect(status().isNotFound()); + } + + private UUID createProduct() throws Exception { + MvcResult created = mockMvc.perform(post("/api/v1/admin/products") + .header("Authorization", "Bearer " + loginAsAdmin()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"Imaged Widget","description":"d","unitPrice":{"amount":"500.0000","currency":"INR"},"availableQty":1} + """)) + .andExpect(status().isCreated()).andReturn(); + return UUID.fromString(json.readTree(created.getResponse().getContentAsString()).get("id").asText()); + } + + private String loginAsAdmin() throws Exception { + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"admin@shopsphere.local","password":"admin12345admin"} + """)) + .andExpect(status().isOk()).andReturn(); + return json.readTree(login.getResponse().getContentAsString()).get("accessToken").asText(); + } + + private String registerAndLogin() throws Exception { + String body = """ + {"email":"img-it+%s@example.com","password":"hunter2hunter2"} + """.formatted(UUID.randomUUID()); + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isCreated()); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andReturn(); + return json.readTree(login.getResponse().getContentAsString()).get("accessToken").asText(); + } +} diff --git a/src/test/java/com/shopsphere/catalog/ProductImageStorageIT.java b/src/test/java/com/shopsphere/catalog/ProductImageStorageIT.java new file mode 100644 index 0000000..1300b2f --- /dev/null +++ b/src/test/java/com/shopsphere/catalog/ProductImageStorageIT.java @@ -0,0 +1,68 @@ +package com.shopsphere.catalog; + +import com.shopsphere.SharedContainers; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link ProductImageStorage} against a real S3 API (LocalStack via Testcontainers, so the dev/test + * path exercises the same code as cloud). An uploaded object is fetchable through its presigned URL; + * once the URL's TTL elapses the same URL is rejected. + */ +@SpringBootTest +class ProductImageStorageIT { + + @DynamicPropertySource + static void containers(DynamicPropertyRegistry registry) { + SharedContainers.registerProperties(registry); + } + + @Autowired + ProductImageStorage storage; + + private final HttpClient http = HttpClient.newHttpClient(); + + @Test + void uploadedImageIsFetchableViaPresignedUrl() throws Exception { + UUID productId = UUID.randomUUID(); + byte[] bytes = "the-image-bytes".getBytes(StandardCharsets.UTF_8); + + String key = storage.upload(productId, bytes, "image/png"); + String url = storage.presignedRead(key, Duration.ofMinutes(5)); + + HttpResponse response = http.send( + HttpRequest.newBuilder(URI.create(url)).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).isEqualTo(bytes); + } + + @Test + void presignedUrlIsRejectedAfterItExpires() throws Exception { + UUID productId = UUID.randomUUID(); + String key = storage.upload(productId, "x".getBytes(StandardCharsets.UTF_8), "image/png"); + + String url = storage.presignedRead(key, Duration.ofSeconds(2)); + Thread.sleep(3_000); + + HttpResponse response = http.send( + HttpRequest.newBuilder(URI.create(url)).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertThat(response.statusCode()).isEqualTo(403); + } +} diff --git a/terraform/s3/.gitignore b/terraform/s3/.gitignore new file mode 100644 index 0000000..80ad845 --- /dev/null +++ b/terraform/s3/.gitignore @@ -0,0 +1,6 @@ +# Never commit local Terraform state, provider binaries, or real tfvars. +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.backup +terraform.tfvars diff --git a/terraform/s3/README.md b/terraform/s3/README.md new file mode 100644 index 0000000..8ba2721 --- /dev/null +++ b/terraform/s3/README.md @@ -0,0 +1,42 @@ +# Phase 16 — product-image S3 bucket (authored, apply deferred) + +A **private** S3 bucket for product images. This is **authored, not applied** — the same +posture as the secrets Terraform (ADR-0013): the dev/test path runs entirely on LocalStack, +so the real bucket is only needed when ShopSphere runs on real AWS. The application does not +depend on this existing. See **ADR-0016**. + +## What it creates + +- A private bucket with **all public access blocked** at every lever. +- `BucketOwnerEnforced` ownership (ACLs disabled). +- SSE-S3 (AES256) encryption at rest. + +No bucket policy grants anonymous read. The app reaches objects only through short-lived +**presigned URLs** minted with its own IAM credentials. + +## Connecting the app (the dev↔cloud seam) + +The app picks its storage backend purely by configuration — the same idea as the RDS +`${DB_HOST}` seam. To point the unchanged binary at this bucket instead of LocalStack: + +| Env var | LocalStack (dev) | Real S3 (this bucket) | +|----------------|-------------------------------|---------------------------------------| +| `S3_ENDPOINT` | `http://localhost:4566` | *(unset — SDK resolves real S3)* | +| `S3_BUCKET` | `shopsphere-product-images` | the `bucket_name` output | +| `AWS_REGION` | `us-east-1` | the `region` output | +| credentials | `S3_ACCESS_KEY/S3_SECRET_KEY` | instance profile / default AWS chain | + +Scope the app's IAM policy to `s3:GetObject` + `s3:PutObject` on the `bucket_arn` and its `/*`. + +## When you actually apply (on a lab / own AWS) + +``` +cp terraform.tfvars.example terraform.tfvars # set a globally-unique bucket_name +terraform init +terraform plan +terraform apply +# ... use it ... +terraform destroy +``` + +Do **not** commit `terraform.tfvars`, `.terraform/`, or `*.tfstate`. diff --git a/terraform/s3/main.tf b/terraform/s3/main.tf new file mode 100644 index 0000000..7acc6ec --- /dev/null +++ b/terraform/s3/main.tf @@ -0,0 +1,57 @@ +# ShopSphere — Phase 16: private S3 bucket for product images. +# +# AUTHORED, NOT APPLIED. Like the secrets work (ADR-0013), this is deferred: the +# dev/test path runs entirely on LocalStack (Testcontainers + docker-compose), so the +# real bucket is only needed when ShopSphere runs on real AWS. `terraform apply` here is +# a future step on a lab/own-AWS — the app does not depend on it existing. See ADR-0016. +# +# Posture is the opposite of the throwaway RDS box: this bucket is PRIVATE. Public access +# is blocked at every lever; the app reaches objects only through short-lived presigned +# URLs minted with its own IAM credentials. No bucket policy grants anonymous read. + +terraform { + required_version = ">= 1.5" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +resource "aws_s3_bucket" "product_images" { + bucket = var.bucket_name +} + +# Deny every form of public access. Presigned URLs still work — they authenticate as the +# app's IAM principal, not as the public — so this does not affect the read path. +resource "aws_s3_bucket_public_access_block" "product_images" { + bucket = aws_s3_bucket.product_images.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# Bucket-owner-enforced: ACLs off, ownership is unambiguous. Modern S3 default. +resource "aws_s3_bucket_ownership_controls" "product_images" { + bucket = aws_s3_bucket.product_images.id + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +# Encrypt at rest with SSE-S3 (AES256). No KMS key to manage (XP YAGNI) — upgrade to +# aws:kms only if a compliance requirement appears. +resource "aws_s3_bucket_server_side_encryption_configuration" "product_images" { + bucket = aws_s3_bucket.product_images.id + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} diff --git a/terraform/s3/outputs.tf b/terraform/s3/outputs.tf new file mode 100644 index 0000000..9bb8e3b --- /dev/null +++ b/terraform/s3/outputs.tf @@ -0,0 +1,14 @@ +output "bucket_name" { + description = "Bucket name — set as the app's S3_BUCKET." + value = aws_s3_bucket.product_images.id +} + +output "bucket_arn" { + description = "Bucket ARN — scope the app's IAM policy (s3:GetObject/PutObject) to this and its /*." + value = aws_s3_bucket.product_images.arn +} + +output "region" { + description = "Region — set as the app's AWS_REGION." + value = var.aws_region +} diff --git a/terraform/s3/terraform.tfvars.example b/terraform/s3/terraform.tfvars.example new file mode 100644 index 0000000..27444d2 --- /dev/null +++ b/terraform/s3/terraform.tfvars.example @@ -0,0 +1,4 @@ +# Copy to terraform.tfvars (gitignored) and adjust before a real apply. +# S3 bucket names are globally unique — pick something nobody else has taken. +aws_region = "us-east-1" +bucket_name = "shopsphere-product-images-CHANGEME" diff --git a/terraform/s3/variables.tf b/terraform/s3/variables.tf new file mode 100644 index 0000000..56ce625 --- /dev/null +++ b/terraform/s3/variables.tf @@ -0,0 +1,11 @@ +variable "aws_region" { + description = "AWS region for the bucket. Match the app's AWS_REGION." + type = string + default = "us-east-1" +} + +variable "bucket_name" { + description = "Product-image bucket name. S3 bucket names are GLOBALLY unique, so the default will likely collide — override with a unique suffix (e.g. shopsphere-product-images-). Feeds the app's S3_BUCKET." + type = string + default = "shopsphere-product-images" +}