Commit c2fab13b81cd35dd08f4bae130ad1287dcdd44fc
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.
Showing
5 changed files
with
223 additions
and
0 deletions
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
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 | +} | ... | ... |
-
mentioned in commit 34c8c92f