Commit c2fab13b81cd35dd08f4bae130ad1287dcdd44fc

Authored by zichun
1 parent 82c5267d

feat(platform-files): S3-compatible file storage backend — closes P1.9

Adds S3FileStorage alongside the existing LocalDiskFileStorage,
selected at boot by vibeerp.files.backend (local or s3). The
local backend is the default (matchIfMissing=true) so existing
deployments are unaffected. Setting backend=s3 activates the S3
backend with its own config block.

Works with AWS S3, MinIO, DigitalOcean Spaces, or any
S3-compatible object store via the endpoint-url override. The
S3 client is lazy-initialized on first use so the bean loads
even when S3 is unreachable at boot time (useful for tests and
for the local-disk default path where the S3 bean is never
instantiated).

Configuration (vibeerp.files.s3.*):
  - bucket (required when backend=s3)
  - region (default: us-east-1)
  - endpoint-url (optional; for MinIO and non-AWS services)
  - access-key + secret-key (optional; falls back to AWS
    DefaultCredentialsProvider chain)
  - key-prefix (optional; namespaces objects so multiple
    instances can share one bucket)

Implementation notes:
  - put() reads the stream into a byte array for S3 (S3
    requires Content-Length up front; chunked upload is a
    future optimization for large files)
  - get() returns the S3 response InputStream directly;
    caller must close it (same contract as local backend)
  - list() paginates via ContinuationToken for buckets with
    >1000 objects per prefix
  - Content-type is stored as native S3 object metadata
    (no sidecar .meta file unlike local backend)

Dependency: software.amazon.awssdk:s3:2.28.6 (AWS SDK v2)
added to libs.versions.toml and platform-files build.gradle.kts.

LocalDiskFileStorage gained @ConditionalOnProperty(havingValue
= "local", matchIfMissing = true) so it's the default but
doesn't conflict when backend=s3.

application.yaml updated with commented-out S3 config block
documenting all available properties.
distribution/src/main/resources/application.yaml
... ... @@ -113,8 +113,19 @@ vibeerp:
113 113 fallback-locale: en-US
114 114 available-locales: en-US,zh-CN,de-DE,ja-JP,es-ES
115 115 files:
  116 + # "local" or "s3". Local-disk is the default; S3 is for cloud
  117 + # deployments or when multiple instances share one object store.
116 118 backend: local
117 119 local-path: ${VIBEERP_FILES_DIR:/opt/vibe-erp/files}
  120 + # S3 config (only read when backend=s3). Works with AWS S3,
  121 + # MinIO, DigitalOcean Spaces, or any S3-compatible service.
  122 + # s3:
  123 + # bucket: ${VIBEERP_S3_BUCKET:}
  124 + # region: ${VIBEERP_S3_REGION:us-east-1}
  125 + # endpoint-url: ${VIBEERP_S3_ENDPOINT:}
  126 + # access-key: ${VIBEERP_S3_ACCESS_KEY:}
  127 + # secret-key: ${VIBEERP_S3_SECRET_KEY:}
  128 + # key-prefix: ${VIBEERP_S3_KEY_PREFIX:}
118 129  
119 130 logging:
120 131 level:
... ...
gradle/libs.versions.toml
... ... @@ -11,6 +11,7 @@ flowable = "7.0.1"
11 11 jasperreports = "6.21.3"
12 12 springdoc = "2.6.0"
13 13 icu4j = "75.1"
  14 +awsSdk = "2.28.6"
14 15 jackson = "2.18.0"
15 16 junitJupiter = "5.11.2"
16 17 mockk = "1.13.13"
... ... @@ -68,6 +69,9 @@ springdoc-openapi-starter-webmvc-ui = { module = "org.springdoc:springdoc-openap
68 69 # i18n
69 70 icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" }
70 71  
  72 +# Object storage (S3-compatible; also works with MinIO, DigitalOcean Spaces, etc.)
  73 +aws-s3 = { module = "software.amazon.awssdk:s3", version.ref = "awsSdk" }
  74 +
71 75 # Testing
72 76 junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" }
73 77 mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
... ...
platform/platform-files/build.gradle.kts
... ... @@ -29,6 +29,7 @@ dependencies {
29 29  
30 30 implementation(libs.spring.boot.starter)
31 31 implementation(libs.spring.boot.starter.web)
  32 + implementation(libs.aws.s3)
32 33  
33 34 testImplementation(libs.spring.boot.starter.test)
34 35 testImplementation(libs.junit.jupiter)
... ...
platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/LocalDiskFileStorage.kt
... ... @@ -2,6 +2,7 @@ package org.vibeerp.platform.files
2 2  
3 3 import org.slf4j.LoggerFactory
4 4 import org.springframework.beans.factory.annotation.Value
  5 +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
5 6 import org.springframework.stereotype.Component
6 7 import org.vibeerp.api.v1.files.FileHandle
7 8 import org.vibeerp.api.v1.files.FileReadResult
... ... @@ -46,6 +47,12 @@ import java.time.Instant
46 47 * MetadataLoader uses for plug-in JAR paths.
47 48 */
48 49 @Component
  50 +@ConditionalOnProperty(
  51 + prefix = "vibeerp.files",
  52 + name = ["backend"],
  53 + havingValue = "local",
  54 + matchIfMissing = true,
  55 +)
49 56 class LocalDiskFileStorage(
50 57 @Value("\${vibeerp.files.local-path:./files-local}")
51 58 rootPath: String,
... ...
platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/S3FileStorage.kt 0 → 100644
  1 +package org.vibeerp.platform.files
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.beans.factory.annotation.Value
  5 +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
  6 +import org.springframework.stereotype.Component
  7 +import org.vibeerp.api.v1.files.FileHandle
  8 +import org.vibeerp.api.v1.files.FileReadResult
  9 +import org.vibeerp.api.v1.files.FileStorage
  10 +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
  11 +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
  12 +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
  13 +import software.amazon.awssdk.core.sync.RequestBody
  14 +import software.amazon.awssdk.regions.Region
  15 +import software.amazon.awssdk.services.s3.S3Client
  16 +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest
  17 +import software.amazon.awssdk.services.s3.model.GetObjectRequest
  18 +import software.amazon.awssdk.services.s3.model.HeadObjectRequest
  19 +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request
  20 +import software.amazon.awssdk.services.s3.model.NoSuchKeyException
  21 +import software.amazon.awssdk.services.s3.model.PutObjectRequest
  22 +import java.io.InputStream
  23 +import java.net.URI
  24 +import java.time.Instant
  25 +
  26 +/**
  27 + * S3-compatible implementation of api.v1 [FileStorage].
  28 + *
  29 + * Activated when `vibeerp.files.backend=s3` is set in config.
  30 + * Works with AWS S3, MinIO, DigitalOcean Spaces, or any
  31 + * S3-compatible object store via the optional `endpoint-url`
  32 + * override.
  33 + *
  34 + * **Credential resolution.** If `vibeerp.files.s3.access-key` and
  35 + * `secret-key` are set, the client uses static credentials. If
  36 + * they're absent, the AWS SDK's `DefaultCredentialsProvider` chain
  37 + * kicks in (env vars, instance profile, ECS task role, etc.).
  38 + *
  39 + * **Key mapping.** Object keys are prefixed with
  40 + * `vibeerp.files.s3.key-prefix` (default empty) so multiple
  41 + * vibe_erp instances can share a single bucket with non-overlapping
  42 + * namespaces if needed.
  43 + *
  44 + * **Content-type.** Stored as the S3 object's native
  45 + * `Content-Type` metadata, so no sidecar `.meta` file is needed
  46 + * (unlike the local-disk backend).
  47 + */
  48 +@Component
  49 +@ConditionalOnProperty(
  50 + prefix = "vibeerp.files",
  51 + name = ["backend"],
  52 + havingValue = "s3",
  53 +)
  54 +class S3FileStorage(
  55 + @Value("\${vibeerp.files.s3.bucket}") private val bucket: String,
  56 + @Value("\${vibeerp.files.s3.region:us-east-1}") private val region: String,
  57 + @Value("\${vibeerp.files.s3.endpoint-url:}") private val endpointUrl: String,
  58 + @Value("\${vibeerp.files.s3.access-key:}") private val accessKey: String,
  59 + @Value("\${vibeerp.files.s3.secret-key:}") private val secretKey: String,
  60 + @Value("\${vibeerp.files.s3.key-prefix:}") private val keyPrefix: String,
  61 +) : FileStorage {
  62 +
  63 + private val log = LoggerFactory.getLogger(S3FileStorage::class.java)
  64 +
  65 + private val s3: S3Client by lazy {
  66 + val builder = S3Client.builder()
  67 + .region(Region.of(region))
  68 +
  69 + if (endpointUrl.isNotBlank()) {
  70 + builder.endpointOverride(URI.create(endpointUrl))
  71 + builder.forcePathStyle(true)
  72 + }
  73 + if (accessKey.isNotBlank() && secretKey.isNotBlank()) {
  74 + builder.credentialsProvider(
  75 + StaticCredentialsProvider.create(
  76 + AwsBasicCredentials.create(accessKey, secretKey),
  77 + ),
  78 + )
  79 + } else {
  80 + builder.credentialsProvider(DefaultCredentialsProvider.create())
  81 + }
  82 +
  83 + builder.build().also {
  84 + log.info("S3FileStorage initialised: bucket={}, region={}, prefix='{}', endpoint={}",
  85 + bucket, region, keyPrefix, endpointUrl.ifBlank { "(default)" })
  86 + }
  87 + }
  88 +
  89 + private fun fullKey(key: String): String {
  90 + require(key.isNotBlank()) { "file key must not be blank" }
  91 + require(!key.contains("..")) { "file key must not contain '..'" }
  92 + return if (keyPrefix.isBlank()) key else "$keyPrefix/$key"
  93 + }
  94 +
  95 + override fun put(key: String, contentType: String, content: InputStream): FileHandle {
  96 + val fk = fullKey(key)
  97 + val bytes = content.readAllBytes()
  98 +
  99 + s3.putObject(
  100 + PutObjectRequest.builder()
  101 + .bucket(bucket)
  102 + .key(fk)
  103 + .contentType(contentType)
  104 + .build(),
  105 + RequestBody.fromBytes(bytes),
  106 + )
  107 +
  108 + val now = Instant.now()
  109 + return FileHandle(
  110 + key = key,
  111 + size = bytes.size.toLong(),
  112 + contentType = contentType,
  113 + createdAt = now,
  114 + updatedAt = now,
  115 + )
  116 + }
  117 +
  118 + override fun get(key: String): FileReadResult? {
  119 + val fk = fullKey(key)
  120 + return try {
  121 + val response = s3.getObject(
  122 + GetObjectRequest.builder()
  123 + .bucket(bucket)
  124 + .key(fk)
  125 + .build(),
  126 + )
  127 + val meta = response.response()
  128 + val handle = FileHandle(
  129 + key = key,
  130 + size = meta.contentLength(),
  131 + contentType = meta.contentType() ?: "application/octet-stream",
  132 + createdAt = meta.lastModified() ?: Instant.now(),
  133 + updatedAt = meta.lastModified() ?: Instant.now(),
  134 + )
  135 + FileReadResult(handle = handle, content = response)
  136 + } catch (_: NoSuchKeyException) {
  137 + null
  138 + }
  139 + }
  140 +
  141 + override fun exists(key: String): Boolean {
  142 + val fk = fullKey(key)
  143 + return try {
  144 + s3.headObject(
  145 + HeadObjectRequest.builder()
  146 + .bucket(bucket)
  147 + .key(fk)
  148 + .build(),
  149 + )
  150 + true
  151 + } catch (_: NoSuchKeyException) {
  152 + false
  153 + }
  154 + }
  155 +
  156 + override fun delete(key: String): Boolean {
  157 + val fk = fullKey(key)
  158 + val existed = exists(key)
  159 + s3.deleteObject(
  160 + DeleteObjectRequest.builder()
  161 + .bucket(bucket)
  162 + .key(fk)
  163 + .build(),
  164 + )
  165 + return existed
  166 + }
  167 +
  168 + override fun list(prefix: String): List<FileHandle> {
  169 + val fk = if (keyPrefix.isBlank()) prefix else "$keyPrefix/$prefix"
  170 + val result = mutableListOf<FileHandle>()
  171 +
  172 + var request = ListObjectsV2Request.builder()
  173 + .bucket(bucket)
  174 + .prefix(fk)
  175 + .build()
  176 +
  177 + do {
  178 + val response = s3.listObjectsV2(request)
  179 + for (obj in response.contents()) {
  180 + val userKey = if (keyPrefix.isBlank()) {
  181 + obj.key()
  182 + } else {
  183 + obj.key().removePrefix("$keyPrefix/")
  184 + }
  185 + result += FileHandle(
  186 + key = userKey,
  187 + size = obj.size(),
  188 + contentType = "application/octet-stream",
  189 + createdAt = obj.lastModified() ?: Instant.now(),
  190 + updatedAt = obj.lastModified() ?: Instant.now(),
  191 + )
  192 + }
  193 + request = request.toBuilder()
  194 + .continuationToken(response.nextContinuationToken())
  195 + .build()
  196 + } while (response.isTruncated)
  197 +
  198 + return result.sortedBy { it.key }
  199 + }
  200 +}
... ...