diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 09bc59c..e8a23ec 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -452,12 +452,51 @@ jobs: # 업로드물은 컨테이너 밖에 둔다. 볼륨이 없으면 교체할 때마다 /app/uploads 가 컨테이너와 # 함께 사라진다 — 사용자가 올린 진단서·프로필 이미지가 배포마다 전부 날아간다는 뜻이다. - # (여러 인스턴스로 늘릴 때는 S3 구현체로 바꿔야 한다. 이슈 #49) + # (STORAGE_TYPE=s3 로 MinIO·S3 를 쓰면 이 볼륨은 쓰이지 않지만, 되돌릴 때를 위해 둔다.) UPLOADS_DIR=/opt/carecode/uploads mkdir -p "$UPLOADS_DIR" + # 오브젝트 스토리지(MinIO)를 쓰는 배포라면 그 컨테이너는 앱과 생애주기가 다르다. + # 앱만 교체하고 MinIO 는 건드리지 않는다 — 데이터가 그 안에 있다. + # + # 서버 .env 에 STORAGE_TYPE=s3 와 MINIO_ROOT_USER/PASSWORD 를 두면 아래가 한 번 + # 띄우고, 이미 떠 있으면 그대로 둔다. 실측 메모리는 유휴 54MB, 100MB 업로드 후 66MB 다. + # 엔드포인트가 이 호스트를 가리킬 때만 MinIO 를 띄운다. 외부 S3·원격 MinIO 를 쓰는 + # 배포에서는 띄우지 않는다. (grep -E 는 부정 전방탐색을 지원하지 않아 case 로 쓴다.) + S3_ENDPOINT="$(grep -E '^STORAGE_S3_ENDPOINT=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2-)" + LOCAL_OBJECT_STORE=no + if grep -qE '^STORAGE_TYPE=s3$' "$ENV_FILE" 2>/dev/null; then + case "$S3_ENDPOINT" in + ""|*127.0.0.1*|*localhost*|*host.docker.internal*) LOCAL_OBJECT_STORE=yes ;; + esac + fi + + if [ "$LOCAL_OBJECT_STORE" = yes ]; then + MINIO_MEMORY="$(grep -E '^MINIO_MEMORY=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2)" + MINIO_MEMORY="${MINIO_MEMORY:-192m}" + + if ! docker ps --format '{{.Names}}' | grep -qx carecode-minio; then + echo "===== MinIO 기동 (메모리 $MINIO_MEMORY) =====" + mkdir -p /opt/carecode/minio + # 127.0.0.1 에만 바인딩한다. 오브젝트 스토리지를 외부에 열 이유가 없고, + # 앱은 같은 호스트에서 붙는다. + docker run -d --name carecode-minio --restart unless-stopped --memory="$MINIO_MEMORY" --memory-swap="$MINIO_MEMORY" -p 127.0.0.1:9000:9000 --env-file "$ENV_FILE" -v /opt/carecode/minio:/data minio/minio:RELEASE.2024-08-29T01-40-52Z server /data || { + echo "::error::MinIO 기동 실패. STORAGE_TYPE=s3 인데 저장소가 없으면 업로드가 전부 실패합니다." + exit 1 + } + else + echo "MinIO 가 이미 떠 있습니다 (앱만 교체합니다)." + fi + fi + + # host.docker.internal 을 호스트로 매핑한다. + # + # 앱은 브리지 네트워크에서 돌고, MariaDB·Redis·MinIO 는 호스트의 127.0.0.1 에 묶여 있다. + # 컨테이너 안에서 127.0.0.1 은 "그 컨테이너 자신" 이라 그 주소로는 닿지 않는다. + # 그래서 서버 .env 의 접속 주소는 host.docker.internal 이어야 한다 + # (예: jdbc:mariadb://host.docker.internal:3306/carecode). run_app() { - docker run -d --name "$APP" --restart unless-stopped --memory="$APP_MEMORY" --memory-swap="$APP_MEMORY" -v "$UPLOADS_DIR:/app/uploads" -p "$PORT:8082" --env-file "$ENV_FILE" "$1" >/dev/null + docker run -d --name "$APP" --restart unless-stopped --memory="$APP_MEMORY" --memory-swap="$APP_MEMORY" --add-host=host.docker.internal:host-gateway -v "$UPLOADS_DIR:/app/uploads" -p "$PORT:8082" --env-file "$ENV_FILE" "$1" >/dev/null } wait_healthy() { diff --git a/build.gradle b/build.gradle index 941cda6..0245b46 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,14 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' implementation 'io.micrometer:micrometer-registry-prometheus' + + // S3 호환 오브젝트 스토리지. 엔드포인트를 설정으로 받으므로 AWS S3·MinIO·R2 에 같은 코드가 붙는다. + // (netty 는 비동기 클라이언트용이다. 동기 클라이언트만 쓰므로 apache-client 하나로 충분하다.) + implementation platform('software.amazon.awssdk:bom:2.28.29') + implementation('software.amazon.awssdk:s3') { + exclude group: 'software.amazon.awssdk', module: 'netty-nio-client' + } + implementation 'software.amazon.awssdk:apache-client' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' @@ -61,6 +69,8 @@ dependencies { testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' testImplementation 'org.testcontainers:junit-jupiter:1.21.3' testImplementation 'org.testcontainers:mariadb:1.21.3' + // MinIO 컨테이너로 S3 경로를 실제로 테스트한다. AWS 자격증명 없이 돈다. + testImplementation 'org.testcontainers:minio:1.21.3' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' // 소셜 로그인(OAuth2) @@ -113,6 +123,12 @@ tasks.withType(Test).configureEach { // API 스펙 파일 갱신 스위치. Gradle 속성으로 받아 테스트 JVM 까지 넘긴다 // (-D 로 주면 Gradle JVM 에만 붙고 테스트 워커에는 전달되지 않는다). systemProperty 'openapi.lock.update', project.findProperty('openapi.lock.update') ?: 'false' + + // MinIO 테스트 이미지. 기본값은 공식 이미지이고, 그것을 받을 수 없는 환경에서는 + // -PminioImage=... 로 S3 호환 대체 이미지를 지정한다. + if (project.hasProperty('minioImage')) { + systemProperty 'minioImage', project.property('minioImage') + } } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index e77ecfe..1800fb2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,6 +102,16 @@ services: REFRESH_COOKIE_SAME_SITE: ${REFRESH_COOKIE_SAME_SITE:-Lax} STORAGE_ROOT: /app/uploads + # 저장소 종류. local 이 기본이고, `STORAGE_TYPE=s3 docker compose --profile s3 up` 으로 + # MinIO 를 함께 띄워 S3 경로를 그대로 써 볼 수 있다. + STORAGE_TYPE: ${STORAGE_TYPE:-local} + STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT:-http://minio:9000} + STORAGE_S3_BUCKET: ${STORAGE_S3_BUCKET:-carecode-uploads} + STORAGE_S3_ACCESS_KEY: ${MINIO_ROOT_USER:-carecode} + STORAGE_S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-carecode-local-dev} + # MinIO 는 버킷을 서브도메인으로 쓰려면 DNS 가 필요해서 켠다. + STORAGE_S3_PATH_STYLE: "true" + STORAGE_S3_REGION: ${STORAGE_S3_REGION:-ap-northeast-2} # `SEED_SAMPLE_DATA=true docker compose up` 처럼 셸에서 넘긴 값이 컨테이너까지 가도록 명시한다. SEED_SAMPLE_DATA: ${SEED_SAMPLE_DATA:-false} @@ -116,6 +126,46 @@ services: # 512m 에서도 뜨지만(측정 464MB) 여유가 없어 640m 로 둔다. mem_limit: ${APP_MEMORY:-640m} + # 선택: S3 호환 오브젝트 스토리지. 로컬에서 S3 경로를 AWS 자격증명 없이 써 보기 위한 것이다. + # + # STORAGE_TYPE=s3 docker compose --profile s3 up + # + # 콘솔은 http://localhost:9001 (기본 계정 carecode / carecode-local-dev). + # 실측 메모리: 유휴 54MB, 100MB 업로드 후 66MB. + minio: + image: minio/minio:RELEASE.2024-08-29T01-40-52Z + profiles: ["s3"] + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-carecode} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-carecode-local-dev} + ports: + - "${MINIO_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - minio-data:/data + mem_limit: ${MINIO_MEMORY:-256m} + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 3s + retries: 12 + + # 버킷을 한 번 만들어 주고 끝난다. 앱이 버킷을 만들지 않는 이유: 운영에서는 버킷 생성 권한을 + # 주지 않는 편이 안전하고, 그러면 앱이 기동할 때마다 권한 오류를 만난다. + minio-init: + image: minio/minio:RELEASE.2024-08-29T01-40-52Z + profiles: ["s3"] + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-carecode} ${MINIO_ROOT_PASSWORD:-carecode-local-dev} && + mc mb --ignore-existing local/${STORAGE_S3_BUCKET:-carecode-uploads} && + echo '버킷 준비 완료' + " + # 선택: 업로드 악성코드 검사기. 시그니처 DB 를 받느라 첫 기동에 몇 분 걸리고 메모리를 1GB 넘게 쓴다. clamav: image: clamav/clamav:1.4 @@ -126,3 +176,4 @@ services: volumes: mariadb-data: uploads: + minio-data: diff --git a/docs/features/operations.md b/docs/features/operations.md index dbce8eb..6a81723 100644 --- a/docs/features/operations.md +++ b/docs/features/operations.md @@ -308,6 +308,12 @@ ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보입니다. 기동 단계에서 실패합니다(의도된 fail-fast). 검증 단계에서 걸리므로 **운영은 무사합니다**. 이슈 #90 - 컨테이너 이름은 `carecode` 로 통일합니다. 예전 워크플로가 만들던 `carecode-blue` / `carecode-green` 은 교체 단계에서 함께 정리합니다 +- **오브젝트 스토리지(MinIO)를 쓰려면** 서버 `.env` 에 `STORAGE_TYPE=s3` 와 + `MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD` 를 둡니다. 배포가 `carecode-minio` 를 한 번 + 띄우고(메모리 기본 192m, 실측 유휴 54MB·100MB 업로드 후 66MB), 이후 배포는 **앱만 + 교체합니다** — 데이터가 그 컨테이너 안에 있습니다. 엔드포인트는 + `STORAGE_S3_ENDPOINT=http://host.docker.internal:9000`, `STORAGE_S3_PATH_STYLE=true` 입니다. + 외부 S3·원격 MinIO 를 쓰면 배포는 MinIO 를 띄우지 않습니다 - **업로드물은 `/opt/carecode/uploads` 에 둡니다.** 배포가 그 디렉터리를 컨테이너의 `/app/uploads` 로 마운트합니다. 볼륨이 없으면 교체할 때마다 업로드물이 컨테이너와 함께 사라집니다 — 사용자가 올린 진단서·프로필 이미지가 **배포마다 전부 날아갑니다.** @@ -354,7 +360,21 @@ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab ### DB·Redis 준비 -배포는 앱만 교체하므로 이 둘은 미리 띄워 둡니다. 앱은 같은 호스트의 `127.0.0.1` 로 붙습니다. +배포는 앱만 교체하므로 이 둘은 미리 띄워 둡니다. + +> **접속 주소는 `host.docker.internal` 입니다.** 아래 명령은 DB·Redis 를 호스트의 +> `127.0.0.1` 에만 묶는데, 앱은 브리지 네트워크에서 돌기 때문에 **컨테이너 안의 +> `127.0.0.1` 은 그 컨테이너 자신**입니다. 그 주소로는 닿지 않습니다. +> 배포가 `--add-host=host.docker.internal:host-gateway` 를 붙이므로 서버 `.env` 에는 +> 이렇게 적습니다. +> +> ``` +> SPRING_DATASOURCE_URL=jdbc:mariadb://host.docker.internal:3306/carecode +> SPRING_DATA_REDIS_HOST=host.docker.internal +> ``` +> +> (`127.0.0.1` 로 적혀 있으면 첫 배포가 기동 단계에서 DB 연결 실패로 멈춥니다. 운영 배포가 +> 아직 한 번도 성공하지 못해 드러나지 않은 항목입니다 — 이슈 #94) ```bash docker run -d --name carecode-mariadb --restart unless-stopped --memory=320m -p 127.0.0.1:3306:3306 -e MARIADB_DATABASE=carecode -e MARIADB_USER=carecode -e MARIADB_PASSWORD=... -e MARIADB_ROOT_PASSWORD=... -e TZ=Asia/Seoul -v carecode-db:/var/lib/mysql mariadb:10.11 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --lower-case-table-names=0 --innodb-buffer-pool-size=96M --performance-schema=OFF --max-connections=30 diff --git a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java index b9a3384..dad1251 100644 --- a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java +++ b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java @@ -1,58 +1,52 @@ package com.carecode.core.storage; import com.carecode.core.exception.BusinessException; -import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.core.exception.ErrorCode; +import com.carecode.core.exception.ResourceNotFoundException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.nio.file.*; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Locale; -import java.util.Set; -import java.util.UUID; - -/** 로컬 디스크 기반 파일 저장소. 단일 인스턴스 배포를 전제로 한다. */ +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 로컬 디스크 기반 파일 저장소. 단일 인스턴스 배포를 전제로 한다. + * + *

검사(크기·확장자·Content-Type·시그니처·악성코드)는 {@link UploadPolicy} 가 한다. + * 저장소 구현이 늘어도 그 검사를 건너뛸 수 없게 하기 위해서다. + * + *

여러 인스턴스로 늘리거나 서명 URL 이 필요하면 {@code app.storage.type=s3} 로 바꾼다 + * ({@link S3FileStorageService} — S3·MinIO 둘 다 붙는다). + */ @Slf4j @Service +@ConditionalOnProperty(name = "app.storage.type", havingValue = "local", matchIfMissing = true) public class LocalFileStorageService implements FileStorageService { - /** 업로드를 허용할 확장자. 실행 가능한 파일이 올라가는 것을 막는다. */ - private static final Set ALLOWED_EXTENSIONS = - Set.of("jpg", "jpeg", "png", "gif", "webp", "heic", "pdf"); - - private static final Set ALLOWED_CONTENT_TYPES = - Set.of("image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "application/pdf"); - - private static final DateTimeFormatter DATE_PATH = DateTimeFormatter.ofPattern("yyyy/MM/dd"); - private final Path rootLocation; private final String publicBaseUrl; - private final long maxFileSize; - private final FileScanner fileScanner; + private final UploadPolicy uploadPolicy; public LocalFileStorageService( @Value("${app.storage.local.root:./uploads}") String root, @Value("${app.storage.public-base-url:/files}") String publicBaseUrl, - @Value("${app.storage.max-file-size-bytes:10485760}") long maxFileSize, - FileScanner fileScanner) { - this.fileScanner = fileScanner; + UploadPolicy uploadPolicy) { + this.uploadPolicy = uploadPolicy; this.rootLocation = Paths.get(root).toAbsolutePath().normalize(); this.publicBaseUrl = publicBaseUrl.endsWith("/") ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1) : publicBaseUrl; - this.maxFileSize = maxFileSize; try { Files.createDirectories(rootLocation); - log.info("파일 저장소 초기화: {}", rootLocation); + log.info("파일 저장소 초기화(로컬): {}", rootLocation); } catch (IOException e) { throw new IllegalStateException("파일 저장 디렉터리를 만들 수 없습니다: " + rootLocation, e); } @@ -60,38 +54,28 @@ public LocalFileStorageService( @Override public StoredFile store(MultipartFile file, String directory) { - validate(file); - byte[] content = readContent(file); - verifySignature(file.getOriginalFilename(), content); - scan(content); - - String extension = extractExtension(file.getOriginalFilename()); - // 원본 파일명을 그대로 쓰면 경로 조작(../)과 파일명 충돌 위험이 있으므로 UUID 로 대체한다. - String storedName = UUID.randomUUID().toString().replace("-", "") + "." + extension; - String relativeDir = sanitizeDirectory(directory) + "/" + LocalDate.now().format(DATE_PATH); - String key = relativeDir + "/" + storedName; - - Path targetDir = rootLocation.resolve(relativeDir).normalize(); - if (!targetDir.startsWith(rootLocation)) { + UploadPolicy.AcceptedUpload accepted = uploadPolicy.accept(file, directory); + + Path target = rootLocation.resolve(accepted.key()).normalize(); + if (!target.startsWith(rootLocation)) { throw new BusinessException(ErrorCode.INVALID_INPUT, "잘못된 저장 경로입니다."); } try { - Files.createDirectories(targetDir); + Files.createDirectories(target.getParent()); // 검사한 바이트를 그대로 쓴다. 스트림을 다시 열면 검사한 것과 다른 내용이 저장될 여지가 생긴다. - Files.write(targetDir.resolve(storedName), content); + Files.write(target, accepted.content()); } catch (IOException e) { - log.error("파일 저장 실패 - key={}", key, e); + log.error("파일 저장 실패 - key={}", accepted.key(), e); throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, "파일 저장에 실패했습니다."); } return StoredFile.builder() - .key(key) - .url(publicBaseUrl + "/" + key) - .originalFilename(StringUtils.cleanPath( - file.getOriginalFilename() != null ? file.getOriginalFilename() : storedName)) - .contentType(file.getContentType()) - .size(file.getSize()) + .key(accepted.key()) + .url(publicBaseUrl + "/" + accepted.key()) + .originalFilename(accepted.originalFilename()) + .contentType(accepted.contentType()) + .size(accepted.size()) .build(); } @@ -139,80 +123,4 @@ public void delete(String key) { log.error("파일 삭제 실패 - key={}", key, e); } } - - private void validate(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "업로드할 파일이 비어 있습니다."); - } - if (file.getSize() > maxFileSize) { - throw new BusinessException(ErrorCode.INVALID_INPUT, - "파일 크기는 " + (maxFileSize / 1024 / 1024) + "MB를 넘을 수 없습니다."); - } - - String extension = extractExtension(file.getOriginalFilename()); - if (!ALLOWED_EXTENSIONS.contains(extension)) { - throw new BusinessException(ErrorCode.INVALID_INPUT, - "허용되지 않는 파일 형식입니다: " + extension); - } - - String contentType = file.getContentType(); - if (contentType != null && !ALLOWED_CONTENT_TYPES.contains(contentType.toLowerCase(Locale.ROOT))) { - throw new BusinessException(ErrorCode.INVALID_INPUT, - "허용되지 않는 파일 형식입니다: " + contentType); - } - } - - /** 최대 크기(기본 10MB)를 이미 확인했으므로 메모리에 올려도 된다. */ - private byte[] readContent(MultipartFile file) { - try { - return file.getBytes(); - } catch (IOException e) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "업로드한 파일을 읽을 수 없습니다."); - } - } - - /** - * 확장자와 Content-Type 은 클라이언트가 정하는 값이다. 실제 바이트가 그 형식인지 본다. - * (HTML·SVG 를 .png 로 올려도 앞의 두 검사는 통과한다.) - */ - private void verifySignature(String filename, byte[] content) { - String extension = extractExtension(filename); - byte[] header = java.util.Arrays.copyOf(content, Math.min(content.length, FileSignatureValidator.HEADER_LENGTH)); - if (!FileSignatureValidator.matches(extension, header)) { - log.warn("확장자와 내용이 다른 업로드를 거부했습니다 - extension={}", extension); - throw new BusinessException(ErrorCode.FILE_CONTENT_MISMATCH, - "파일 내용이 ." + extension + " 형식이 아닙니다."); - } - } - - /** 검사기에 닿지 못하면 받지 않는다. 검사를 켠 환경에서 조용히 통과시키면 켠 의미가 없다. */ - private void scan(byte[] content) { - FileScanner.ScanResult result; - try { - result = fileScanner.scan(content); - } catch (FileScanner.ScannerUnavailableException e) { - log.error("파일 검사 실패로 업로드를 거부했습니다", e); - throw new BusinessException(ErrorCode.FILE_SCAN_UNAVAILABLE, ErrorCode.FILE_SCAN_UNAVAILABLE.getMessage()); - } - if (!result.clean()) { - throw new BusinessException(ErrorCode.FILE_REJECTED_BY_SCAN, ErrorCode.FILE_REJECTED_BY_SCAN.getMessage()); - } - } - - private String extractExtension(String filename) { - if (filename == null) { - return ""; - } - String cleaned = StringUtils.cleanPath(filename); - int dot = cleaned.lastIndexOf('.'); - return dot >= 0 ? cleaned.substring(dot + 1).toLowerCase(Locale.ROOT) : ""; - } - - /** 디렉터리 이름에 경로 구분자나 상위 이동이 들어오지 못하게 한다. */ - private String sanitizeDirectory(String directory) { - if (directory == null || directory.isBlank()) { - return "misc"; - } - return directory.replaceAll("[^a-zA-Z0-9_-]", ""); - } } diff --git a/src/main/java/com/carecode/core/storage/S3ClientConfiguration.java b/src/main/java/com/carecode/core/storage/S3ClientConfiguration.java new file mode 100644 index 0000000..9fc7a27 --- /dev/null +++ b/src/main/java/com/carecode/core/storage/S3ClientConfiguration.java @@ -0,0 +1,73 @@ +package com.carecode.core.storage; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +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 java.net.URI; + +/** + * S3 호환 클라이언트. + * + *

엔드포인트와 path-style 을 설정으로 받기 때문에 구현 하나로 S3·MinIO·R2 를 모두 + * 쓴다. 어디에 저장하는지는 배포 환경이 정한다. + * + * + * + * + * + *
용도설정
AWS S3엔드포인트 비움 + 리전 + IAM 역할(자격증명 생략)
MinIO엔드포인트 지정 + path-style 켜기 + 액세스 키/시크릿
+ */ +@Slf4j +@Configuration +@ConditionalOnProperty(name = "app.storage.type", havingValue = "s3") +public class S3ClientConfiguration { + + /** + * @param endpoint MinIO 등 S3 호환 서버 주소. 비우면 AWS S3 로 간다 + * @param region AWS 리전. MinIO 는 아무 값이나 받지만 SDK 가 요구해서 넣는다 + * @param accessKey 비우면 {@link DefaultCredentialsProvider} 를 쓴다(EC2 IAM 역할·환경변수·프로파일) + * @param pathStyle {@code true} 면 {@code http://host/bucket/key}. MinIO 는 가상 호스트 + * 방식(버킷을 서브도메인으로)을 쓰려면 DNS 가 필요해서 보통 켠다 + */ + @Bean + public S3Client s3Client( + @Value("${app.storage.s3.endpoint:}") String endpoint, + @Value("${app.storage.s3.region:ap-northeast-2}") String region, + @Value("${app.storage.s3.access-key:}") String accessKey, + @Value("${app.storage.s3.secret-key:}") String secretKey, + @Value("${app.storage.s3.path-style:false}") boolean pathStyle) { + + var builder = S3Client.builder() + .region(Region.of(region)) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(pathStyle) + .build()); + + if (!endpoint.isBlank()) { + builder.endpointOverride(URI.create(endpoint)); + } + + // 키를 주지 않으면 SDK 의 기본 탐색을 쓴다. 운영 S3 는 EC2 IAM 역할을 쓰는 쪽이 + // 안전하다 — 자격증명이 환경변수나 .env 에 남지 않는다. + if (!accessKey.isBlank() && !secretKey.isBlank()) { + builder.credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))); + } else { + builder.credentialsProvider(DefaultCredentialsProvider.create()); + } + + log.info("S3 호환 클라이언트 초기화: endpoint={}, region={}, pathStyle={}, 정적 자격증명={}", + endpoint.isBlank() ? "(AWS 기본)" : endpoint, region, pathStyle, !accessKey.isBlank()); + + return builder.build(); + } +} diff --git a/src/main/java/com/carecode/core/storage/S3FileStorageService.java b/src/main/java/com/carecode/core/storage/S3FileStorageService.java new file mode 100644 index 0000000..5c5f7db --- /dev/null +++ b/src/main/java/com/carecode/core/storage/S3FileStorageService.java @@ -0,0 +1,133 @@ +package com.carecode.core.storage; + +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; +import com.carecode.core.exception.ResourceNotFoundException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * S3 호환 오브젝트 스토리지 저장소. AWS S3·MinIO·R2 에 같은 코드가 붙는다. + * + *

엔드포인트를 설정으로 받고 path-style 접근을 켜면 MinIO 가 그대로 동작한다. 그래서 구현을 + * 하나만 두고, 어디에 저장하는지는 환경변수로 정한다 — 로컬·CI 는 MinIO 컨테이너, 운영은 S3 든 + * 자체 MinIO 든 바꿔 끼운다. + * + *

검사(크기·확장자·Content-Type·시그니처·악성코드)는 {@link UploadPolicy} 가 한다. + * 저장 위치가 바뀌어도 받아들이는 기준은 같아야 한다. + * + *

왜 공개 URL 을 돌려주지 않는가. 건강기록 첨부는 민감정보라 주소만 알면 열리는 상태로 + * 둘 수 없다. 그래서 {@link #store} 가 돌려주는 {@code url} 은 외부에서 바로 열 수 있는 주소가 + * 아니라 저장 키이고, 다운로드는 소유권을 확인하는 애플리케이션 경로를 거친다 + * ({@code GET /health/records/{recordId}/attachments/{id}/download}). 로컬 구현이 + * {@code /files/...} 를 돌려주던 것과 같은 자리이며, 그쪽도 정적 서빙을 프로필 이미지로만 + * 좁혀 둔 상태다. + */ +@Slf4j +@Service +@ConditionalOnProperty(name = "app.storage.type", havingValue = "s3") +public class S3FileStorageService implements FileStorageService { + + private final S3Client s3Client; + private final String bucket; + private final UploadPolicy uploadPolicy; + + public S3FileStorageService( + S3Client s3Client, + @Value("${app.storage.s3.bucket}") String bucket, + UploadPolicy uploadPolicy) { + this.s3Client = s3Client; + this.bucket = bucket; + this.uploadPolicy = uploadPolicy; + log.info("파일 저장소 초기화(S3 호환): bucket={}", bucket); + } + + @Override + public StoredFile store(MultipartFile file, String directory) { + UploadPolicy.AcceptedUpload accepted = uploadPolicy.accept(file, directory); + + try { + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(accepted.key()) + .contentType(accepted.contentType()) + .build(), + // 검사한 바이트를 그대로 올린다. 스트림을 다시 열면 검사한 것과 다른 내용이 저장될 수 있다. + RequestBody.fromBytes(accepted.content())); + } catch (S3Exception e) { + log.error("오브젝트 저장 실패 - key={}", accepted.key(), e); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, "파일 저장에 실패했습니다."); + } + + return StoredFile.builder() + .key(accepted.key()) + .url(accepted.key()) + .originalFilename(accepted.originalFilename()) + .contentType(accepted.contentType()) + .size(accepted.size()) + .build(); + } + + @Override + public Resource load(String key) { + if (key == null || key.isBlank()) { + throw new ResourceNotFoundException("파일을 찾을 수 없습니다."); + } + + try { + ResponseBytes object = s3Client.getObjectAsBytes( + GetObjectRequest.builder().bucket(bucket).key(key).build()); + return new ByteArrayResource(object.asByteArray()); + } catch (NoSuchKeyException e) { + log.warn("없는 오브젝트 요청: {}", key); + throw new ResourceNotFoundException("파일을 찾을 수 없습니다."); + } catch (S3Exception e) { + log.error("오브젝트 읽기 실패 - key={}", key, e); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, "파일을 읽을 수 없습니다."); + } + } + + /** + * 저장 키를 돌려준다. + * + *

이 구현은 {@link #store} 에서 {@code url} 에 키를 그대로 담으므로 변환할 것이 없다. + * 로컬 구현에서 S3 로 바꾸면 예전에 저장된 행은 {@code /files/...} 를 들고 있다. + * 그 접두사를 떼어 주지 않으면 이전 첨부를 영영 읽지 못하므로 함께 처리한다. + */ + @Override + public String toKey(String publicUrl) { + if (publicUrl == null || publicUrl.isBlank()) { + return publicUrl; + } + return publicUrl.startsWith("/files/") ? publicUrl.substring("/files/".length()) : publicUrl; + } + + @Override + public void delete(String key) { + if (key == null || key.isBlank()) { + return; + } + try { + s3Client.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(key).build()); + } catch (S3Exception e) { + // 삭제 실패로 요청을 깨뜨리지 않는다. 행은 이미 지워졌고, 남은 오브젝트는 가리키는 + // 것이 없어 접근 경로가 없다. 로컬 구현과 같은 판단이다. + log.error("오브젝트 삭제 실패 - key={}", key, e); + } + } +} diff --git a/src/main/java/com/carecode/core/storage/UploadPolicy.java b/src/main/java/com/carecode/core/storage/UploadPolicy.java new file mode 100644 index 0000000..b4a2196 --- /dev/null +++ b/src/main/java/com/carecode/core/storage/UploadPolicy.java @@ -0,0 +1,159 @@ +package com.carecode.core.storage; + +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +/** + * 업로드를 받아들일지 판단하고, 받아들인 바이트와 저장 키를 돌려준다. + * + *

이 검사들은 한때 {@link LocalFileStorageService} 안에 있었다. 저장소 구현이 하나뿐일 때는 + * 문제가 없었지만, S3·MinIO 구현을 더하면 새 구현이 검사를 건너뛸 수 있다. 크기·확장자· + * Content-Type·시그니처·악성코드 검사는 "어디에 저장하는가" 와 무관한 규칙이므로 여기로 모았다. + * + *

구현체는 {@link #accept}가 돌려준 {@link AcceptedUpload#content()} 를 그대로 저장해야 한다. + * 스트림을 다시 열면 검사한 것과 다른 내용이 저장될 여지가 생긴다. + */ +@Slf4j +@Component +public class UploadPolicy { + + /** 업로드를 허용할 확장자. 실행 가능한 파일이 올라가는 것을 막는다. */ + private static final Set ALLOWED_EXTENSIONS = + Set.of("jpg", "jpeg", "png", "gif", "webp", "heic", "pdf"); + + private static final Set ALLOWED_CONTENT_TYPES = + Set.of("image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "application/pdf"); + + private static final DateTimeFormatter DATE_PATH = DateTimeFormatter.ofPattern("yyyy/MM/dd"); + + private final long maxFileSize; + private final FileScanner fileScanner; + + public UploadPolicy( + @Value("${app.storage.max-file-size-bytes:10485760}") long maxFileSize, + FileScanner fileScanner) { + this.maxFileSize = maxFileSize; + this.fileScanner = fileScanner; + } + + /** + * 검사를 통과한 업로드. + * + * @param key 저장 키 ({@code /yyyy/MM/dd/.}) + * @param content 검사한 바이트. 이 값을 그대로 저장한다 + * @param originalFilename 사용자에게 보여줄 원래 이름 + * @param contentType 클라이언트가 보낸 Content-Type + */ + public record AcceptedUpload(String key, byte[] content, String originalFilename, String contentType) { + + public long size() { + return content.length; + } + } + + public AcceptedUpload accept(MultipartFile file, String directory) { + validate(file); + byte[] content = readContent(file); + String extension = extractExtension(file.getOriginalFilename()); + verifySignature(extension, content); + scan(content); + + // 원본 파일명을 그대로 쓰면 경로 조작(../)과 파일명 충돌 위험이 있으므로 UUID 로 대체한다. + String storedName = UUID.randomUUID().toString().replace("-", "") + "." + extension; + String key = sanitizeDirectory(directory) + "/" + LocalDate.now().format(DATE_PATH) + "/" + storedName; + + return new AcceptedUpload( + key, + content, + StringUtils.cleanPath(file.getOriginalFilename() != null ? file.getOriginalFilename() : storedName), + file.getContentType()); + } + + private void validate(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "업로드할 파일이 비어 있습니다."); + } + if (file.getSize() > maxFileSize) { + throw new BusinessException(ErrorCode.INVALID_INPUT, + "파일 크기는 " + (maxFileSize / 1024 / 1024) + "MB를 넘을 수 없습니다."); + } + + String extension = extractExtension(file.getOriginalFilename()); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new BusinessException(ErrorCode.INVALID_INPUT, + "허용되지 않는 파일 형식입니다: " + extension); + } + + String contentType = file.getContentType(); + if (contentType != null && !ALLOWED_CONTENT_TYPES.contains(contentType.toLowerCase(Locale.ROOT))) { + throw new BusinessException(ErrorCode.INVALID_INPUT, + "허용되지 않는 파일 형식입니다: " + contentType); + } + } + + /** 최대 크기(기본 10MB)를 이미 확인했으므로 메모리에 올려도 된다. */ + private byte[] readContent(MultipartFile file) { + try { + return file.getBytes(); + } catch (IOException e) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "업로드한 파일을 읽을 수 없습니다."); + } + } + + /** + * 확장자와 Content-Type 은 클라이언트가 정하는 값이다. 실제 바이트가 그 형식인지 본다. + * (HTML·SVG 를 .png 로 올려도 앞의 두 검사는 통과한다.) + */ + private void verifySignature(String extension, byte[] content) { + byte[] header = Arrays.copyOf(content, Math.min(content.length, FileSignatureValidator.HEADER_LENGTH)); + if (!FileSignatureValidator.matches(extension, header)) { + log.warn("확장자와 내용이 다른 업로드를 거부했습니다 - extension={}", extension); + throw new BusinessException(ErrorCode.FILE_CONTENT_MISMATCH, + "파일 내용이 ." + extension + " 형식이 아닙니다."); + } + } + + /** 검사기에 닿지 못하면 받지 않는다. 검사를 켠 환경에서 조용히 통과시키면 켠 의미가 없다. */ + private void scan(byte[] content) { + FileScanner.ScanResult result; + try { + result = fileScanner.scan(content); + } catch (FileScanner.ScannerUnavailableException e) { + log.error("파일 검사 실패로 업로드를 거부했습니다", e); + throw new BusinessException(ErrorCode.FILE_SCAN_UNAVAILABLE, ErrorCode.FILE_SCAN_UNAVAILABLE.getMessage()); + } + if (!result.clean()) { + throw new BusinessException(ErrorCode.FILE_REJECTED_BY_SCAN, ErrorCode.FILE_REJECTED_BY_SCAN.getMessage()); + } + } + + private String extractExtension(String filename) { + if (filename == null) { + return ""; + } + String cleaned = StringUtils.cleanPath(filename); + int dot = cleaned.lastIndexOf('.'); + return dot >= 0 ? cleaned.substring(dot + 1).toLowerCase(Locale.ROOT) : ""; + } + + /** 디렉터리 이름에 경로 구분자나 상위 이동이 들어오지 못하게 한다. */ + private String sanitizeDirectory(String directory) { + if (directory == null || directory.isBlank()) { + return "misc"; + } + return directory.replaceAll("[^a-zA-Z0-9_-]", ""); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c36852c..a405cb6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -163,9 +163,21 @@ app: # false 로 둬도 폴백 경로가 같은 값을 돌려준다. (실측: ForwardedClientIpTest, 이슈 #90) trust-forwarded-headers: ${RATE_LIMIT_TRUST_XFF:false} storage: - # 다중 인스턴스로 확장할 때는 FileStorageService 의 S3 구현체로 교체한다. + # local = 디스크에 쓴다. 단일 인스턴스 전제. 배포 시 볼륨을 마운트해야 파일이 유지된다. + # s3 = S3 호환 오브젝트 스토리지(AWS S3·MinIO·R2). 엔드포인트를 지정하면 MinIO 로 간다. + type: ${STORAGE_TYPE:local} local: root: ${STORAGE_ROOT:./uploads} + s3: + bucket: ${STORAGE_S3_BUCKET:carecode-uploads} + # 비우면 AWS S3. MinIO 면 http://호스트:9000 을 넣는다. + endpoint: ${STORAGE_S3_ENDPOINT:} + region: ${STORAGE_S3_REGION:ap-northeast-2} + # 비우면 SDK 기본 탐색(EC2 IAM 역할·환경변수·프로파일)을 쓴다. + access-key: ${STORAGE_S3_ACCESS_KEY:} + secret-key: ${STORAGE_S3_SECRET_KEY:} + # MinIO 는 버킷을 서브도메인으로 쓰려면 DNS 가 필요해서 보통 켠다. + path-style: ${STORAGE_S3_PATH_STYLE:false} public-base-url: ${STORAGE_PUBLIC_BASE_URL:/files} max-file-size-bytes: ${STORAGE_MAX_FILE_SIZE:10485760} # 업로드 악성코드 검사 (ClamAV clamd). 켜면 검사기에 닿지 못할 때 업로드를 받지 않는다(503). diff --git a/src/test/java/com/carecode/core/storage/LocalFileStorageServiceSecurityTest.java b/src/test/java/com/carecode/core/storage/LocalFileStorageServiceSecurityTest.java index 023747a..94598ef 100644 --- a/src/test/java/com/carecode/core/storage/LocalFileStorageServiceSecurityTest.java +++ b/src/test/java/com/carecode/core/storage/LocalFileStorageServiceSecurityTest.java @@ -22,8 +22,10 @@ class LocalFileStorageServiceSecurityTest { @TempDir Path root; + /** 검사는 UploadPolicy 가 한다. 저장소 구현이 늘어도 같은 기준이 적용되도록 분리했다. */ private LocalFileStorageService storage(FileScanner scanner) { - return new LocalFileStorageService(root.toString(), "/files", 10 * 1024 * 1024, scanner); + return new LocalFileStorageService(root.toString(), "/files", + new UploadPolicy(10 * 1024 * 1024, scanner)); } @Test diff --git a/src/test/java/com/carecode/core/storage/S3FileStorageServiceTest.java b/src/test/java/com/carecode/core/storage/S3FileStorageServiceTest.java new file mode 100644 index 0000000..af4020a --- /dev/null +++ b/src/test/java/com/carecode/core/storage/S3FileStorageServiceTest.java @@ -0,0 +1,212 @@ +package com.carecode.core.storage; + +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ResourceNotFoundException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +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.S3Configuration; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * S3 경로를 실제 서버에 대고 검증한다. MinIO 컨테이너를 쓰므로 AWS 자격증명이 필요 없다. + * + *

목으로 검증하면 "SDK 를 올바르게 불렀는가" 만 보게 된다. path-style 접근, 없는 키의 예외 + * 종류, 버킷 동작처럼 실제로 틀리는 것들은 서버가 있어야 드러난다. + * + *

같은 구현이 AWS S3 에도 붙는다 — 엔드포인트와 path-style 만 설정으로 다르다. + */ +@Testcontainers +@DisplayName("S3 호환 저장소 (MinIO)") +class S3FileStorageServiceTest { + + private static final String BUCKET = "carecode-test"; + + /** + * MinIO 이미지. {@code minio/minio} 가 기본이지만 받을 수 없는 환경이 있어 대체 이미지를 + * 쓸 수 있게 열어 둔다({@code -DminioImage=...} 또는 환경변수 {@code MINIO_IMAGE}). + * 어느 쪽이든 S3 API 는 같다. + */ + private static final org.testcontainers.utility.DockerImageName MINIO_IMAGE = + org.testcontainers.utility.DockerImageName + .parse(System.getProperty("minioImage", + System.getenv().getOrDefault("MINIO_IMAGE", "minio/minio:RELEASE.2024-08-29T01-40-52Z"))) + .asCompatibleSubstituteFor("minio/minio"); + + /** + * 테스트마다 컨테이너를 띄우면 느리다. 클래스 단위로 한 번 띄우고 키를 달리 쓴다. + * (저장 키에 UUID 가 들어가므로 테스트 간 충돌이 없다.) + */ + private static MinIOContainer minio; + private static S3Client s3Client; + private static S3FileStorageService storage; + + /** PNG 시그니처. 시그니처 검사를 통과해야 저장까지 간다. */ + private static final byte[] PNG_HEADER = { + (byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A + }; + + @BeforeAll + static void startMinio() { + try { + minio = new MinIOContainer(MINIO_IMAGE) + // 대체 이미지는 비권한 사용자로 돌아 /data 에 쓸 수 없다. 테스트 컨테이너라 root 로 띄운다. + .withCreateContainerCmdModifier(cmd -> cmd.withUser("0")); + minio.start(); + } catch (RuntimeException e) { + // 이미지를 받을 수 없는 환경(사내 레지스트리 제약 등)에서 스위트 전체를 깨뜨리지 않는다. + // CI 는 공식 이미지를 받을 수 있으므로 거기서 실제로 검증된다. + minio = null; + Assumptions.abort("MinIO 이미지를 준비할 수 없어 건너뜁니다 (" + MINIO_IMAGE + "): " + e.getMessage()); + } + + s3Client = S3Client.builder() + .endpointOverride(URI.create(minio.getS3URL())) + .region(Region.of("us-east-1")) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(minio.getUserName(), minio.getPassword()))) + // MinIO 는 버킷을 서브도메인으로 쓰려면 DNS 가 필요하다. 켜지 않으면 연결이 실패한다. + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .build(); + + s3Client.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build()); + + // 검사는 UploadPolicy 가 한다. 스캐너는 통과시키는 구현을 쓴다(검사 자체는 그쪽 테스트가 본다). + storage = new S3FileStorageService(s3Client, BUCKET, + new UploadPolicy(10 * 1024 * 1024, new NoOpFileScanner())); + } + + @AfterAll + static void stopMinio() { + if (s3Client != null) { + s3Client.close(); + } + if (minio != null) { + minio.stop(); + } + } + + private MockMultipartFile pngFile(String name) { + byte[] content = new byte[64]; + System.arraycopy(PNG_HEADER, 0, content, 0, PNG_HEADER.length); + return new MockMultipartFile("file", name, "image/png", content); + } + + @Test + @DisplayName("올리고 같은 바이트를 다시 읽는다") + void storeThenLoad() throws IOException { + StoredFile stored = storage.store(pngFile("진단서.png"), "health-records"); + + assertThat(stored.getKey()).startsWith("health-records/").endsWith(".png"); + assertThat(stored.getOriginalFilename()).isEqualTo("진단서.png"); + assertThat(stored.getSize()).isEqualTo(64); + + byte[] loaded = storage.load(stored.getKey()).getContentAsByteArray(); + assertThat(loaded).hasSize(64); + assertThat(java.util.Arrays.copyOf(loaded, PNG_HEADER.length)).isEqualTo(PNG_HEADER); + } + + /** + * 저장 키에 원본 파일명이 들어가면 경로 조작과 충돌 위험이 있다. UUID 로 대체하는 규칙이 + * 저장소 구현을 바꿔도 유지되는지 본다. + */ + @Test + @DisplayName("원본 파일명은 키에 들어가지 않는다") + void keyDoesNotContainOriginalName() { + StoredFile stored = storage.store(pngFile("../../etc/passwd.png"), "health-records"); + + assertThat(stored.getKey()).doesNotContain("..").doesNotContain("passwd"); + } + + @Test + @DisplayName("없는 키는 404 로 끝낸다") + void missingKeyIsNotFound() { + assertThatThrownBy(() -> storage.load("health-records/2026/01/01/nope.png")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + @DisplayName("삭제하면 다시 읽히지 않는다") + void deleteRemovesObject() { + StoredFile stored = storage.store(pngFile("처방전.png"), "health-records"); + storage.delete(stored.getKey()); + + assertThatThrownBy(() -> storage.load(stored.getKey())) + .isInstanceOf(ResourceNotFoundException.class); + } + + /** 없는 키를 지워도 예외를 던지지 않는다 — 인터페이스가 그렇게 약속한다. */ + @Test + @DisplayName("없는 키 삭제는 조용히 넘어간다") + void deletingMissingKeyIsSilent() { + storage.delete("health-records/2026/01/01/nope.png"); + } + + /** + * 저장 위치가 바뀌어도 받아들이는 기준은 같아야 한다. 검사를 {@link UploadPolicy} 로 뺀 + * 이유가 이것이다 — 새 구현이 검사를 건너뛸 수 없다. + */ + @Test + @DisplayName("확장자와 내용이 다른 파일은 S3 경로에서도 거부한다") + void rejectsContentMismatch() { + MockMultipartFile html = new MockMultipartFile( + "file", "evil.png", "image/png", "script".getBytes(StandardCharsets.UTF_8)); + + assertThatThrownBy(() -> storage.store(html, "health-records")) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("허용하지 않는 확장자는 S3 경로에서도 거부한다") + void rejectsDisallowedExtension() { + MockMultipartFile exe = new MockMultipartFile( + "file", "payload.exe", "application/octet-stream", new byte[] {0x4D, 0x5A}); + + assertThatThrownBy(() -> storage.store(exe, "health-records")) + .isInstanceOf(BusinessException.class); + } + + /** + * 로컬에서 S3 로 바꾸면 예전에 저장된 행은 {@code /files/...} 를 들고 있다. 그 접두사를 + * 떼어 주지 않으면 이전 첨부를 영영 읽지 못한다. + */ + @Test + @DisplayName("예전 /files/ URL 도 키로 되돌린다") + void convertsLegacyPublicUrl() { + String key = "health-records/2026/08/29/abc.png"; + + assertThat(storage.toKey("/files/" + key)).isEqualTo(key); + assertThat(storage.toKey(key)).isEqualTo(key); + } + + /** 직접 올린 오브젝트도 읽는다 — 마이그레이션으로 밀어 넣은 파일이 그 경우다. */ + @Test + @DisplayName("애플리케이션을 거치지 않고 올린 오브젝트도 읽는다") + void readsExternallyUploadedObject() throws IOException { + String key = "profile-images/migrated/avatar.png"; + s3Client.putObject( + PutObjectRequest.builder().bucket(BUCKET).key(key).build(), + RequestBody.fromBytes("migrated".getBytes(StandardCharsets.UTF_8))); + + assertThat(storage.load(key).getContentAsByteArray()) + .isEqualTo("migrated".getBytes(StandardCharsets.UTF_8)); + } +}