diff --git a/distribution/src/main/resources/application.yaml b/distribution/src/main/resources/application.yaml index a8c38d2..a946818 100644 --- a/distribution/src/main/resources/application.yaml +++ b/distribution/src/main/resources/application.yaml @@ -113,8 +113,19 @@ vibeerp: fallback-locale: en-US available-locales: en-US,zh-CN,de-DE,ja-JP,es-ES files: + # "local" or "s3". Local-disk is the default; S3 is for cloud + # deployments or when multiple instances share one object store. backend: local local-path: ${VIBEERP_FILES_DIR:/opt/vibe-erp/files} + # S3 config (only read when backend=s3). Works with AWS S3, + # MinIO, DigitalOcean Spaces, or any S3-compatible service. + # s3: + # bucket: ${VIBEERP_S3_BUCKET:} + # region: ${VIBEERP_S3_REGION:us-east-1} + # endpoint-url: ${VIBEERP_S3_ENDPOINT:} + # access-key: ${VIBEERP_S3_ACCESS_KEY:} + # secret-key: ${VIBEERP_S3_SECRET_KEY:} + # key-prefix: ${VIBEERP_S3_KEY_PREFIX:} logging: level: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be3444a..d571a87 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ flowable = "7.0.1" jasperreports = "6.21.3" springdoc = "2.6.0" icu4j = "75.1" +awsSdk = "2.28.6" jackson = "2.18.0" junitJupiter = "5.11.2" mockk = "1.13.13" @@ -68,6 +69,9 @@ springdoc-openapi-starter-webmvc-ui = { module = "org.springdoc:springdoc-openap # i18n icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } +# Object storage (S3-compatible; also works with MinIO, DigitalOcean Spaces, etc.) +aws-s3 = { module = "software.amazon.awssdk:s3", version.ref = "awsSdk" } + # Testing junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } diff --git a/platform/platform-files/build.gradle.kts b/platform/platform-files/build.gradle.kts index f8ae5f7..7d40f56 100644 --- a/platform/platform-files/build.gradle.kts +++ b/platform/platform-files/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.web) + implementation(libs.aws.s3) testImplementation(libs.spring.boot.starter.test) testImplementation(libs.junit.jupiter) diff --git a/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/LocalDiskFileStorage.kt b/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/LocalDiskFileStorage.kt index 8b077f3..2b2e287 100644 --- a/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/LocalDiskFileStorage.kt +++ b/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/LocalDiskFileStorage.kt @@ -2,6 +2,7 @@ package org.vibeerp.platform.files import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Component import org.vibeerp.api.v1.files.FileHandle import org.vibeerp.api.v1.files.FileReadResult @@ -46,6 +47,12 @@ import java.time.Instant * MetadataLoader uses for plug-in JAR paths. */ @Component +@ConditionalOnProperty( + prefix = "vibeerp.files", + name = ["backend"], + havingValue = "local", + matchIfMissing = true, +) class LocalDiskFileStorage( @Value("\${vibeerp.files.local-path:./files-local}") rootPath: String, diff --git a/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/S3FileStorage.kt b/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/S3FileStorage.kt new file mode 100644 index 0000000..212b989 --- /dev/null +++ b/platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/S3FileStorage.kt @@ -0,0 +1,200 @@ +package org.vibeerp.platform.files + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.files.FileHandle +import org.vibeerp.api.v1.files.FileReadResult +import org.vibeerp.api.v1.files.FileStorage +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.core.sync.RequestBody +import software.amazon.awssdk.regions.Region +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.HeadObjectRequest +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request +import software.amazon.awssdk.services.s3.model.NoSuchKeyException +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import java.io.InputStream +import java.net.URI +import java.time.Instant + +/** + * S3-compatible implementation of api.v1 [FileStorage]. + * + * Activated when `vibeerp.files.backend=s3` is set in config. + * Works with AWS S3, MinIO, DigitalOcean Spaces, or any + * S3-compatible object store via the optional `endpoint-url` + * override. + * + * **Credential resolution.** If `vibeerp.files.s3.access-key` and + * `secret-key` are set, the client uses static credentials. If + * they're absent, the AWS SDK's `DefaultCredentialsProvider` chain + * kicks in (env vars, instance profile, ECS task role, etc.). + * + * **Key mapping.** Object keys are prefixed with + * `vibeerp.files.s3.key-prefix` (default empty) so multiple + * vibe_erp instances can share a single bucket with non-overlapping + * namespaces if needed. + * + * **Content-type.** Stored as the S3 object's native + * `Content-Type` metadata, so no sidecar `.meta` file is needed + * (unlike the local-disk backend). + */ +@Component +@ConditionalOnProperty( + prefix = "vibeerp.files", + name = ["backend"], + havingValue = "s3", +) +class S3FileStorage( + @Value("\${vibeerp.files.s3.bucket}") private val bucket: String, + @Value("\${vibeerp.files.s3.region:us-east-1}") private val region: String, + @Value("\${vibeerp.files.s3.endpoint-url:}") private val endpointUrl: String, + @Value("\${vibeerp.files.s3.access-key:}") private val accessKey: String, + @Value("\${vibeerp.files.s3.secret-key:}") private val secretKey: String, + @Value("\${vibeerp.files.s3.key-prefix:}") private val keyPrefix: String, +) : FileStorage { + + private val log = LoggerFactory.getLogger(S3FileStorage::class.java) + + private val s3: S3Client by lazy { + val builder = S3Client.builder() + .region(Region.of(region)) + + if (endpointUrl.isNotBlank()) { + builder.endpointOverride(URI.create(endpointUrl)) + builder.forcePathStyle(true) + } + if (accessKey.isNotBlank() && secretKey.isNotBlank()) { + builder.credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey), + ), + ) + } else { + builder.credentialsProvider(DefaultCredentialsProvider.create()) + } + + builder.build().also { + log.info("S3FileStorage initialised: bucket={}, region={}, prefix='{}', endpoint={}", + bucket, region, keyPrefix, endpointUrl.ifBlank { "(default)" }) + } + } + + private fun fullKey(key: String): String { + require(key.isNotBlank()) { "file key must not be blank" } + require(!key.contains("..")) { "file key must not contain '..'" } + return if (keyPrefix.isBlank()) key else "$keyPrefix/$key" + } + + override fun put(key: String, contentType: String, content: InputStream): FileHandle { + val fk = fullKey(key) + val bytes = content.readAllBytes() + + s3.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(fk) + .contentType(contentType) + .build(), + RequestBody.fromBytes(bytes), + ) + + val now = Instant.now() + return FileHandle( + key = key, + size = bytes.size.toLong(), + contentType = contentType, + createdAt = now, + updatedAt = now, + ) + } + + override fun get(key: String): FileReadResult? { + val fk = fullKey(key) + return try { + val response = s3.getObject( + GetObjectRequest.builder() + .bucket(bucket) + .key(fk) + .build(), + ) + val meta = response.response() + val handle = FileHandle( + key = key, + size = meta.contentLength(), + contentType = meta.contentType() ?: "application/octet-stream", + createdAt = meta.lastModified() ?: Instant.now(), + updatedAt = meta.lastModified() ?: Instant.now(), + ) + FileReadResult(handle = handle, content = response) + } catch (_: NoSuchKeyException) { + null + } + } + + override fun exists(key: String): Boolean { + val fk = fullKey(key) + return try { + s3.headObject( + HeadObjectRequest.builder() + .bucket(bucket) + .key(fk) + .build(), + ) + true + } catch (_: NoSuchKeyException) { + false + } + } + + override fun delete(key: String): Boolean { + val fk = fullKey(key) + val existed = exists(key) + s3.deleteObject( + DeleteObjectRequest.builder() + .bucket(bucket) + .key(fk) + .build(), + ) + return existed + } + + override fun list(prefix: String): List { + val fk = if (keyPrefix.isBlank()) prefix else "$keyPrefix/$prefix" + val result = mutableListOf() + + var request = ListObjectsV2Request.builder() + .bucket(bucket) + .prefix(fk) + .build() + + do { + val response = s3.listObjectsV2(request) + for (obj in response.contents()) { + val userKey = if (keyPrefix.isBlank()) { + obj.key() + } else { + obj.key().removePrefix("$keyPrefix/") + } + result += FileHandle( + key = userKey, + size = obj.size(), + contentType = "application/octet-stream", + createdAt = obj.lastModified() ?: Instant.now(), + updatedAt = obj.lastModified() ?: Instant.now(), + ) + } + request = request.toBuilder() + .continuationToken(response.nextContinuationToken()) + .build() + } while (response.isTruncated) + + return result.sortedBy { it.key } + } +}