diff --git a/application/src/main/kotlin/com/threedays/application/auth/config/UserProperties.kt b/application/src/main/kotlin/com/threedays/application/auth/config/UserProperties.kt new file mode 100644 index 0000000..583b90f --- /dev/null +++ b/application/src/main/kotlin/com/threedays/application/auth/config/UserProperties.kt @@ -0,0 +1,13 @@ +package com.threedays.application.auth.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "user") +data class UserProperties( + val profileImage: ProfileImageProperties +) { + data class ProfileImageProperties( + val maxContentLength: Long, + val uploadExpiresIn: Long + ) +} diff --git a/application/src/main/kotlin/com/threedays/application/user/port/inbound/CompleteUserProfileImageUpload.kt b/application/src/main/kotlin/com/threedays/application/user/port/inbound/CompleteUserProfileImageUpload.kt new file mode 100644 index 0000000..6264cc0 --- /dev/null +++ b/application/src/main/kotlin/com/threedays/application/user/port/inbound/CompleteUserProfileImageUpload.kt @@ -0,0 +1,16 @@ +package com.threedays.application.user.port.inbound + +import com.threedays.domain.user.entity.User +import com.threedays.domain.user.entity.UserProfileImage + +interface CompleteUserProfileImageUpload { + + fun invoke(command: Command) + + data class Command( + val userId: User.Id, + val imageId: UserProfileImage.Id, + val extension: UserProfileImage.Extension, + ) + +} diff --git a/application/src/main/kotlin/com/threedays/application/user/port/inbound/GetUserProfileImageUploadUrl.kt b/application/src/main/kotlin/com/threedays/application/user/port/inbound/GetUserProfileImageUploadUrl.kt new file mode 100644 index 0000000..fe9f0e2 --- /dev/null +++ b/application/src/main/kotlin/com/threedays/application/user/port/inbound/GetUserProfileImageUploadUrl.kt @@ -0,0 +1,20 @@ +package com.threedays.application.user.port.inbound + +import com.threedays.domain.user.entity.UserProfileImage +import java.net.URL +import java.util.UUID + +interface GetUserProfileImageUploadUrl { + + fun invoke(command: Command): Result + + data class Command(val extension: UserProfileImage.Extension) + + data class Result( + val imageId: UUID, + val extension: UserProfileImage.Extension, + val url: URL, + val uploadExpiresIn: Long + ) + +} diff --git a/application/src/main/kotlin/com/threedays/application/user/port/outbound/UserProfileImagePort.kt b/application/src/main/kotlin/com/threedays/application/user/port/outbound/UserProfileImagePort.kt new file mode 100644 index 0000000..3aac98c --- /dev/null +++ b/application/src/main/kotlin/com/threedays/application/user/port/outbound/UserProfileImagePort.kt @@ -0,0 +1,21 @@ +package com.threedays.application.user.port.outbound + +import com.threedays.domain.user.entity.UserProfileImage +import java.net.URL +import java.util.UUID + +interface UserProfileImagePort { + + fun getUploadUrl( + id: UUID, + extension: UserProfileImage.Extension, + expiresIn: Long, // seconds + maxContentLength: Long, // bytes + ): URL + + fun findImageUrlByIdAndExtension( + id: UserProfileImage.Id, + extension: UserProfileImage.Extension, + ): URL + +} diff --git a/application/src/main/kotlin/com/threedays/application/user/service/UserService.kt b/application/src/main/kotlin/com/threedays/application/user/service/UserService.kt index 974b71f..01a010b 100644 --- a/application/src/main/kotlin/com/threedays/application/user/service/UserService.kt +++ b/application/src/main/kotlin/com/threedays/application/user/service/UserService.kt @@ -1,12 +1,16 @@ package com.threedays.application.user.service import com.threedays.application.auth.config.AuthProperties +import com.threedays.application.auth.config.UserProperties import com.threedays.application.auth.port.inbound.IssueLoginTokens +import com.threedays.application.user.port.inbound.CompleteUserProfileImageUpload import com.threedays.application.user.port.inbound.DeleteProfileWidget +import com.threedays.application.user.port.inbound.GetUserProfileImageUploadUrl import com.threedays.application.user.port.inbound.PutProfileWidget import com.threedays.application.user.port.inbound.RegisterUser import com.threedays.application.user.port.inbound.UpdateDesiredPartner import com.threedays.application.user.port.inbound.UpdateUserInfo +import com.threedays.application.user.port.outbound.UserProfileImagePort import com.threedays.domain.user.entity.Company import com.threedays.domain.user.entity.Location import com.threedays.domain.user.entity.User @@ -15,6 +19,8 @@ import com.threedays.domain.user.repository.LocationQueryRepository import com.threedays.domain.user.repository.UserRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.util.* @Service class UserService( @@ -22,12 +28,11 @@ class UserService( private val locationQueryRepository: LocationQueryRepository, private val companyQueryRepository: CompanyQueryRepository, private val issueLoginTokens: IssueLoginTokens, + private val userProfileImagePort: UserProfileImagePort, private val authProperties: AuthProperties, -) : RegisterUser, - PutProfileWidget, - DeleteProfileWidget, - UpdateUserInfo, - UpdateDesiredPartner { + private val userProperties: UserProperties, +) : RegisterUser, PutProfileWidget, DeleteProfileWidget, UpdateUserInfo, UpdateDesiredPartner, + GetUserProfileImageUploadUrl, CompleteUserProfileImageUpload { @Transactional override fun invoke(command: RegisterUser.Command): RegisterUser.Result { @@ -93,7 +98,6 @@ class UserService( .also { userRepository.save(it) } } - @Transactional override fun invoke(command: UpdateDesiredPartner.Command): User { return userRepository .get(command.userId) @@ -105,4 +109,31 @@ class UserService( .also { userRepository.save(it) } } + override fun invoke(command: GetUserProfileImageUploadUrl.Command): GetUserProfileImageUploadUrl.Result { + val imageId: UUID = UUID.randomUUID() + val uploadUrl: URL = userProfileImagePort.getUploadUrl( + id = imageId, + extension = command.extension, + expiresIn = userProperties.profileImage.uploadExpiresIn, + maxContentLength = userProperties.profileImage.maxContentLength, + ) + + return GetUserProfileImageUploadUrl.Result( + imageId = imageId, + extension = command.extension, + url = uploadUrl, + uploadExpiresIn = userProperties.profileImage.uploadExpiresIn, + ) + } + + override fun invoke(command: CompleteUserProfileImageUpload.Command) { + userRepository + .get(command.userId) + .updateUserProfileImage( + extension = command.extension, + getProfileImageUrlAction = userProfileImagePort::findImageUrlByIdAndExtension, + ) + .let { userRepository.save(it) } + } + } diff --git a/application/src/main/resources/application-application.yaml b/application/src/main/resources/application-application.yaml index 678374c..011e497 100644 --- a/application/src/main/resources/application-application.yaml +++ b/application/src/main/resources/application-application.yaml @@ -4,3 +4,7 @@ auth: access-token-expiration-seconds: ${ACCESS_TOKEN_EXPIRATION_SECONDS:7200} # 2 hours refresh-token-expiration-seconds: ${REFRESH_TOKEN_EXPIRATION_SECONDS:604800} # 1 week register-token-expiration-seconds: ${REGISTER_TOKEN_EXPIRATION_SECONDS:86400} # 1 day +user: + profile-image: + upload-expires-in: ${PROFILE_IMAGE_UPLOAD_EXPIRES_IN:300} # 5 minutes + max-content-length: ${PROFILE_IMAGE_MAX_CONTENT_LENGTH:5242880} # 5MB diff --git a/bootstrap/api/src/main/kotlin/com/threedays/bootstrap/api/user/UserController.kt b/bootstrap/api/src/main/kotlin/com/threedays/bootstrap/api/user/UserController.kt index 1d13f20..c76bf40 100644 --- a/bootstrap/api/src/main/kotlin/com/threedays/bootstrap/api/user/UserController.kt +++ b/bootstrap/api/src/main/kotlin/com/threedays/bootstrap/api/user/UserController.kt @@ -1,6 +1,8 @@ package com.threedays.bootstrap.api.user +import com.threedays.application.user.port.inbound.CompleteUserProfileImageUpload import com.threedays.application.user.port.inbound.DeleteProfileWidget +import com.threedays.application.user.port.inbound.GetUserProfileImageUploadUrl import com.threedays.application.user.port.inbound.PutProfileWidget import com.threedays.application.user.port.inbound.RegisterUser import com.threedays.application.user.port.inbound.UpdateDesiredPartner @@ -10,16 +12,20 @@ import com.threedays.bootstrap.api.support.security.withUserAuthentication import com.threedays.domain.auth.vo.PhoneNumber import com.threedays.domain.user.entity.User import com.threedays.domain.user.entity.UserDesiredPartner +import com.threedays.domain.user.entity.UserProfileImage import com.threedays.domain.user.repository.UserRepository import com.threedays.domain.user.vo.BirthYearRange import com.threedays.domain.user.vo.Gender import com.threedays.domain.user.vo.JobOccupation import com.threedays.oas.api.UsersApi import com.threedays.oas.model.CompanyDisplayInfo +import com.threedays.oas.model.CompleteProfileImageUploadRequest import com.threedays.oas.model.GetMyUserInfoResponse +import com.threedays.oas.model.GetProfileImageUploadUrlResponse import com.threedays.oas.model.JobOccupationDisplayInfo import com.threedays.oas.model.LocationDisplayInfo import com.threedays.oas.model.PreferDistance +import com.threedays.oas.model.ProfileImageExtension import com.threedays.oas.model.ProfileWidget import com.threedays.oas.model.ProfileWidgetType import com.threedays.oas.model.RegisterUserRequest @@ -45,6 +51,8 @@ class UserController( private val deleteProfileWidget: DeleteProfileWidget, private val updateUserInfo: UpdateUserInfo, private val updateDesiredPartner: UpdateDesiredPartner, + private val getUserProfileImageUploadUrl: GetUserProfileImageUploadUrl, + private val completeUserProfileImageUpload: CompleteUserProfileImageUpload, ) : UsersApi { override fun registerUser( @@ -245,4 +253,31 @@ class UserController( ).let { ResponseEntity.ok(it) } } + + override fun completeProfileImageUpload(completeProfileImageUploadRequest: CompleteProfileImageUploadRequest): ResponseEntity = withUserAuthentication { authentication -> + val command = CompleteUserProfileImageUpload.Command( + userId = authentication.userId, + imageId = UUIDTypeId.from(completeProfileImageUploadRequest.imageId), + extension = UserProfileImage.Extension.valueOf(completeProfileImageUploadRequest.extension.name) + ) + completeUserProfileImageUpload.invoke(command) + ResponseEntity.ok().build() + } + + override fun getProfileImageUploadUrl(extension: ProfileImageExtension): ResponseEntity { + val command: GetUserProfileImageUploadUrl.Command = GetUserProfileImageUploadUrl.Command( + extension = UserProfileImage.Extension.valueOf(extension.name) + ) + + val result: GetUserProfileImageUploadUrl.Result = getUserProfileImageUploadUrl.invoke(command) + + return GetProfileImageUploadUrlResponse( + imageId = result.imageId, + url = result.url.toString(), + extension = ProfileImageExtension.valueOf(result.extension.name), + uploadExpiresIn = result.uploadExpiresIn.toInt(), + ).let { + ResponseEntity.ok(it) + } + } } diff --git a/domain/src/main/kotlin/com/threedays/domain/user/entity/User.kt b/domain/src/main/kotlin/com/threedays/domain/user/entity/User.kt index 4e5f429..758c7c8 100644 --- a/domain/src/main/kotlin/com/threedays/domain/user/entity/User.kt +++ b/domain/src/main/kotlin/com/threedays/domain/user/entity/User.kt @@ -2,6 +2,7 @@ package com.threedays.domain.user.entity import com.threedays.domain.auth.vo.PhoneNumber import com.threedays.domain.user.entity.UserDesiredPartner.PreferDistance +import com.threedays.domain.user.entity.UserProfileImage.Extension import com.threedays.domain.user.repository.CompanyQueryRepository import com.threedays.domain.user.repository.LocationQueryRepository import com.threedays.domain.user.vo.BirthYearRange @@ -9,6 +10,7 @@ import com.threedays.domain.user.vo.Gender import com.threedays.domain.user.vo.JobOccupation import com.threedays.support.common.base.domain.AggregateRoot import com.threedays.support.common.base.domain.UUIDTypeId +import java.net.URL import java.time.Year import java.util.* @@ -17,6 +19,7 @@ data class User( override val id: Id, val name: Name, val phoneNumber: PhoneNumber, + val profileImages: List = emptyList(), val profile: UserProfile, val desiredPartner: UserDesiredPartner, ) : AggregateRoot() { @@ -25,6 +28,7 @@ data class User( @JvmInline value class Name(val value: String) { + init { require(value.isNotBlank()) { "이름은 공백일 수 없습니다." } } @@ -135,4 +139,13 @@ data class User( ) } + // TODO: 이미지 여러개 업로드 가능하도록 수정 필요 + fun updateUserProfileImage( + extension: Extension, + getProfileImageUrlAction: (UserProfileImage.Id, Extension) -> URL, + ): User { + val newProfileImage = UserProfileImage.create(extension, getProfileImageUrlAction) + return copy(profileImages = listOf(newProfileImage)) + } + } diff --git a/domain/src/main/kotlin/com/threedays/domain/user/entity/UserProfileImage.kt b/domain/src/main/kotlin/com/threedays/domain/user/entity/UserProfileImage.kt index 162e2e3..8c55995 100644 --- a/domain/src/main/kotlin/com/threedays/domain/user/entity/UserProfileImage.kt +++ b/domain/src/main/kotlin/com/threedays/domain/user/entity/UserProfileImage.kt @@ -27,7 +27,7 @@ class UserProfileImage( extension: Extension, getProfileImageUrlAction: (Id, Extension) -> URL, ): UserProfileImage { - val imageId = Id(UUID.randomUUID()) + val imageId: Id = UUIDTypeId.random() val imageUrl: URL = try { getProfileImageUrlAction(imageId, extension) } catch (e: Exception) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8d0e5f5..68984fa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ java = "21" spring = "6.1.10" spring-boot = "3.3.6" spring-boot-dependency-management = "1.1.6" -kotlin = "2.0.0" +kotlin = "2.1.0" sonar-cloud = "4.4.1.3373" openapi-generator = "7.5.0" jib = "3.4.3" diff --git a/infrastructure/aws/src/main/kotlin/com/threedays/aws/s3/config/S3Config.kt b/infrastructure/aws/src/main/kotlin/com/threedays/aws/s3/config/S3Config.kt new file mode 100644 index 0000000..c1d0e64 --- /dev/null +++ b/infrastructure/aws/src/main/kotlin/com/threedays/aws/s3/config/S3Config.kt @@ -0,0 +1,15 @@ +package com.threedays.aws.s3.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.services.s3.presigner.S3Presigner + +@Configuration +class S3Config { + + @Bean + fun s3Presigner(): S3Presigner { + return S3Presigner.create() + } + +} diff --git a/infrastructure/aws/src/main/kotlin/com/threedays/aws/s3/config/S3Properties.kt b/infrastructure/aws/src/main/kotlin/com/threedays/aws/s3/config/S3Properties.kt new file mode 100644 index 0000000..ec3a398 --- /dev/null +++ b/infrastructure/aws/src/main/kotlin/com/threedays/aws/s3/config/S3Properties.kt @@ -0,0 +1,15 @@ +package com.threedays.aws.s3.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "aws.s3") +class S3Properties( + val userProfileImage: UserProfileImage, +) { + + data class UserProfileImage( + val bucketName: String, + val keyPrefix: String, + ) + +} diff --git a/infrastructure/aws/src/main/kotlin/com/threedays/aws/s3/user/UserProfileImageS3Adapter.kt b/infrastructure/aws/src/main/kotlin/com/threedays/aws/s3/user/UserProfileImageS3Adapter.kt new file mode 100644 index 0000000..52fb172 --- /dev/null +++ b/infrastructure/aws/src/main/kotlin/com/threedays/aws/s3/user/UserProfileImageS3Adapter.kt @@ -0,0 +1,74 @@ +package com.threedays.aws.s3.user + +import com.threedays.application.user.port.outbound.UserProfileImagePort +import com.threedays.aws.s3.config.S3Properties +import com.threedays.domain.user.entity.UserProfileImage +import org.springframework.stereotype.Component +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.GetUrlRequest +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.net.URL +import java.time.Duration +import java.util.* + +@Component +class UserProfileImageS3Adapter( + private val s3Client: S3Client, + private val s3Presigner: S3Presigner, + private val s3Properties: S3Properties, +) : UserProfileImagePort { + + override fun getUploadUrl( + id: UUID, + extension: UserProfileImage.Extension, + expiresIn: Long, // seconds + maxContentLength: Long, // bytes + ): URL { + val putObjectRequest: PutObjectRequest = PutObjectRequest + .builder() + .apply { + bucket(s3Properties.userProfileImage.bucketName) + key(getObjectKey(id, extension)) + contentLength(maxContentLength) + }.build() + + + val presignedRequest: PutObjectPresignRequest = PutObjectPresignRequest + .builder() + .apply { + signatureDuration(Duration.ofSeconds(expiresIn)) + putObjectRequest(putObjectRequest) + } + .build() + + return s3Presigner + .presignPutObject(presignedRequest) + .url() + } + + override fun findImageUrlByIdAndExtension( + id: UserProfileImage.Id, + extension: UserProfileImage.Extension + + ): URL { + val getObjectRequest = GetUrlRequest + .builder() + .bucket(s3Properties.userProfileImage.bucketName) + .key(getObjectKey(id.value, extension)) + .build() + + return s3Client + .utilities() + .getUrl(getObjectRequest) + } + + private fun getObjectKey( + id: UUID, + extension: UserProfileImage.Extension, + ): String { + return "${s3Properties.userProfileImage.keyPrefix}/$id.${extension.value}" + } + +} diff --git a/infrastructure/aws/src/main/resources/application-aws.yaml b/infrastructure/aws/src/main/resources/application-aws.yaml index e69de29..b612c57 100644 --- a/infrastructure/aws/src/main/resources/application-aws.yaml +++ b/infrastructure/aws/src/main/resources/application-aws.yaml @@ -0,0 +1,33 @@ +spring: + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: + static: ${AWS_REGION:ap-northeast-2} +--- +spring: + config: + activate: + on-profile: local +aws: + s3: + user-profile-image: + bucket-name: user-profile-image + key-prefix: user-profile-image/ +--- +spring: + config: + activate: + on-profile: dev +aws: + s3: + user-profile-image: + bucket-name: user-profile-image + key-prefix: user-profile-image/ +--- +spring: + config: + activate: + on-profile: prod diff --git a/infrastructure/persistence/src/main/kotlin/com/threedays/persistence/user/entity/UserJpaEntity.kt b/infrastructure/persistence/src/main/kotlin/com/threedays/persistence/user/entity/UserJpaEntity.kt index ed2557e..cb07a5c 100644 --- a/infrastructure/persistence/src/main/kotlin/com/threedays/persistence/user/entity/UserJpaEntity.kt +++ b/infrastructure/persistence/src/main/kotlin/com/threedays/persistence/user/entity/UserJpaEntity.kt @@ -3,13 +3,17 @@ package com.threedays.persistence.user.entity import com.threedays.domain.auth.vo.PhoneNumber import com.threedays.domain.user.entity.User import com.threedays.persistence.user.entity.UserDesiredPartnerJpaEntity.Companion.toJpaEntity +import com.threedays.persistence.user.entity.UserProfileImageJpaEntity.Companion.toJpaEntity import com.threedays.persistence.user.entity.UserProfileJpaEntity.Companion.toJpaEntity import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.FetchType import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.OneToMany import jakarta.persistence.OneToOne +import jakarta.persistence.OrderColumn import jakarta.persistence.Table import java.util.* @@ -19,6 +23,7 @@ class UserJpaEntity( id: UUID, name: String, phoneNumber: String, + profileImages: List, profile: UserProfileJpaEntity, desiredPartner: UserDesiredPartnerJpaEntity, ) { @@ -35,6 +40,12 @@ class UserJpaEntity( var phoneNumber: String = phoneNumber private set + @OneToMany(fetch = FetchType.EAGER) + @JoinColumn(name = "user_id") + @OrderColumn(name = "image_order") + var profileImages: List = profileImages + private set + @OneToOne( fetch = FetchType.EAGER, cascade = [CascadeType.ALL], @@ -55,6 +66,7 @@ class UserJpaEntity( id = id.value, name = name.value, phoneNumber = phoneNumber.value, + profileImages = profileImages.map { it.toJpaEntity() }, profile = profile.toJpaEntity(), desiredPartner = desiredPartner.toJpaEntity(), ) @@ -64,6 +76,7 @@ class UserJpaEntity( return User( id = User.Id(id), name = User.Name(name), + profileImages = profileImages.map { it.toDomain() }, phoneNumber = PhoneNumber(phoneNumber), profile = profile.toDomainEntity(), desiredPartner = desiredPartner.toDomainEntity(), diff --git a/infrastructure/persistence/src/main/kotlin/com/threedays/persistence/user/entity/UserProfileImageJpaEntity.kt b/infrastructure/persistence/src/main/kotlin/com/threedays/persistence/user/entity/UserProfileImageJpaEntity.kt new file mode 100644 index 0000000..e8c3107 --- /dev/null +++ b/infrastructure/persistence/src/main/kotlin/com/threedays/persistence/user/entity/UserProfileImageJpaEntity.kt @@ -0,0 +1,47 @@ +package com.threedays.persistence.user.entity + +import com.threedays.domain.user.entity.UserProfileImage +import com.threedays.support.common.base.domain.UUIDTypeId +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.net.URI +import java.util.* + +@Entity +@Table(name = "user_profile_images") +class UserProfileImageJpaEntity( + id: UUID, + extension: UserProfileImage.Extension, + url: String, +) { + + @Id + var id: UUID = id + private set + + @Enumerated(EnumType.STRING) + var extension: UserProfileImage.Extension = extension + private set + + var url: String = url + private set + + companion object { + + fun UserProfileImage.toJpaEntity() = UserProfileImageJpaEntity( + id = id.value, + extension = extension, + url = url.toString(), + ) + } + + fun toDomain() = UserProfileImage( + id = UUIDTypeId.from(id), + extension = extension, + url = URI.create(url).toURL(), + ) + +} diff --git a/infrastructure/persistence/src/main/resources/db/migration/V1__init_user.sql b/infrastructure/persistence/src/main/resources/db/migration/V1__init_user.sql index def08ff..19b0a4d 100644 --- a/infrastructure/persistence/src/main/resources/db/migration/V1__init_user.sql +++ b/infrastructure/persistence/src/main/resources/db/migration/V1__init_user.sql @@ -68,3 +68,13 @@ CREATE TABLE user_profile_widgets PRIMARY KEY (user_profile_id, type), FOREIGN KEY (user_profile_id) REFERENCES user_profiles (id) ON DELETE CASCADE ); + +CREATE TABLE user_profile_images +( + id BINARY(16) NOT NULL, + user_id BINARY(16) NOT NULL, + image_order INT NOT NULL, + extension VARCHAR(255) NULL, + url VARCHAR(255) NULL, + PRIMARY KEY (id) +); diff --git a/openapi b/openapi index 59a761e..881119d 160000 --- a/openapi +++ b/openapi @@ -1 +1 @@ -Subproject commit 59a761ea043af65e69cfc6100500b6359381f14d +Subproject commit 881119d0672ad42c42e5f4a055ff145f4a408d50