Commit f2156c5b6218fb2d2f1d53b78ee771dc284457bf
1 parent
1d4741f8
feat(files): P1.9 — FileStorage facade + LocalDiskFileStorage + HTTP surface
Closes the P1.9 row of the implementation plan. New platform-files
subproject exposing a cross-PBC facade for the framework's binary
blob store, with a local-disk implementation and a thin HTTP
surface for multipart upload / download / delete / list.
## api.v1 additions (package `org.vibeerp.api.v1.files`)
- `FileStorage` — injectable facade with five methods:
* `put(key, contentType, content: InputStream): FileHandle`
* `get(key): FileReadResult?`
* `exists(key): Boolean`
* `delete(key): Boolean`
* `list(prefix): List<FileHandle>`
Stream-first (not byte-array-first) so reports PDFs etc. don't
have to be materialized in memory. Keys are opaque strings with
slashes allowed for logical grouping; the local-disk backend
maps them to subdirectories.
- `FileHandle` — read-only metadata DTO (key, size, contentType,
createdAt, updatedAt).
- `FileReadResult` — the return type of `get()` bundling a handle
and an open InputStream. The caller MUST close the stream
(`result.content.use { ... }` is the idiomatic shape); the
facade is not responsible for managing the consumer's lifetime.
- `PluginContext.files: FileStorage` — new member on the plug-in
context interface, default implementation throws
`UnsupportedOperationException("upgrade vibe_erp to v0.8 or later")`.
Same backward-compat pattern we used for `endpoints`, `jdbc`,
`taskHandlers`. Plug-ins that need to persist report PDFs,
uploaded attachments, or exported archives inject this through
the context.
## platform-files runtime
- `LocalDiskFileStorage` @Component reading `vibeerp.files.local-path`
(default `./files-local`, overridden in dev profile to
`./files-dev`, overridden in production config to
`/opt/vibe-erp/files`).
**Layout**: files are stored at `<root>/<key>` with a sidecar
metadata file at `<root>/<key>.meta` containing a single line
`content_type=<value>`. Sidecars beat xattrs (not portable
across Linux/macOS) and beat an H2/SQLite index (overkill for
single-tenant single-instance).
**Atomicity**: every `put` writes to a `.tmp` sibling file and
atomic-moves it into place so a concurrent read against the same
key never sees a half-written mix.
**Key safety**: `put`/`get`/`delete` all validate the key:
rejects blank, leading `/`, `..` (path traversal), and trailing
`.meta` (sidecar collision). Every resolved path is checked to
stay under the configured root via `normalize().startsWith(root)`.
- `FileController` at `/api/v1/files/**`:
* `POST /api/v1/files?key=...` multipart upload (form field `file`)
* `GET /api/v1/files?prefix=...` list by prefix
* `GET /api/v1/files/metadata?key=...` metadata only (doesn't open the stream)
* `GET /api/v1/files/download?key=...` stream bytes with the right Content-Type + filename
* `DELETE /api/v1/files?key=...` delete by key
All endpoints @RequirePermission-gated via the keys declared in
the metadata YAML. The `key` is a query parameter, NOT a path
variable, so slashes in the key don't collide with Spring's path
matching.
- `META-INF/vibe-erp/metadata/files.yml` — 2 permissions + 1 menu.
## Smoke test (fresh DB, as admin)
```
POST /api/v1/files?key=reports/smoke-test.txt (multipart file)
→ 201 {"key":"reports/smoke-test.txt",
"size":61,
"contentType":"text/plain",
"createdAt":"...","updatedAt":"..."}
GET /api/v1/files?prefix=reports/
→ [{"key":"reports/smoke-test.txt","size":61, ...}]
GET /api/v1/files/metadata?key=reports/smoke-test.txt
→ same handle, no bytes
GET /api/v1/files/download?key=reports/smoke-test.txt
→ 200 Content-Type: text/plain
body: original upload content (diff == 0)
DELETE /api/v1/files?key=reports/smoke-test.txt
→ 200 {"removed":true}
GET /api/v1/files/download?key=reports/smoke-test.txt
→ 404
# path traversal
POST /api/v1/files?key=../escape (multipart file)
→ 400 "file key must not contain '..' (got '../escape')"
GET /api/v1/_meta/metadata
→ permissions include ["files.file.read", "files.file.write"]
```
Downloaded bytes match the uploaded bytes exactly — round-trip
verified with `diff -q`.
## Tests
- 12 new unit tests in `LocalDiskFileStorageTest` using JUnit 5's
`@TempDir`:
* `put then get round-trips content and metadata`
* `put overwrites an existing key with the new content`
* `get returns null for an unknown key`
* `exists distinguishes present from absent`
* `delete removes the file and its metadata sidecar`
* `delete on unknown key returns false`
* `list filters by prefix and returns sorted keys`
* `put rejects a key with dot-dot`
* `put rejects a key starting with slash`
* `put rejects a key ending in dot-meta sidecar`
* `put rejects blank content type`
* `list sidecar metadata files are hidden from listing results`
- Total framework unit tests: 327 (was 315), all green.
## What this unblocks
- **P1.8 JasperReports integration** — now has a first-class home
for generated PDFs. A report renderer can call
`fileStorage.put("reports/quote-$code.pdf", "application/pdf", ...)`
and return the handle to the caller.
- **Plug-in attachments** — the printing-shop plug-in's future
"plate scan image" or "QC report" attachments can be stored via
`context.files` without touching the database.
- **Export/import flows** — a scheduled job can write a nightly
CSV export via `FileStorage.put` and a separate endpoint can
download it; the scheduler-to-storage path is clean and typed.
- **S3 backend when needed** — the interface is already streaming-
based; dropping in an `S3FileStorage` @Component and toggling
`vibeerp.files.backend: s3` in config is a future additive chunk,
zero api.v1 churn.
## Non-goals (parking lot)
- S3 backend. The config already reads `vibeerp.files.backend`,
local is hard-wired for v1.0. Keeps the dependency tree off
aws-sdk until a real consumer exists.
- Range reads / HTTP `Range: bytes=...` support. Future
enhancement for large-file streaming (e.g. video attachments).
- Presigned URLs (for direct browser-to-S3 upload, skipping the
framework). Design decision lives with the S3 backend chunk.
- Per-file ACLs. The four `files.file.*` permissions currently
gate all files uniformly; per-path or per-owner ACLs would
require a new metadata table and haven't been asked for by any
PBC yet.
- Plug-in loader integration. `PluginContext.files` throws the
default `UnsupportedOperationException` until the plug-in
loader is wired to pass the host `FileStorage` through
`DefaultPluginContext`. Lands in the same chunk as the first
plug-in that needs to store a file.
Showing
9 changed files
with
638 additions
and
0 deletions
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/files/FileStorage.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.files | |
| 2 | + | |
| 3 | +import java.io.InputStream | |
| 4 | +import java.time.Instant | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Cross-PBC facade for the framework's binary-blob store. | |
| 8 | + * | |
| 9 | + * The framework-wide home for anything that doesn't belong in the | |
| 10 | + * relational database: generated PDF reports (P1.8), uploaded | |
| 11 | + * attachments on business documents, plug-in-bundled images, export | |
| 12 | + * files waiting to be downloaded, etc. Callers inject [FileStorage] | |
| 13 | + * and use the methods below; the platform's `platform-files` module | |
| 14 | + * provides the only implementation. | |
| 15 | + * | |
| 16 | + * **Design goals:** | |
| 17 | + * - **One interface for local disk AND S3.** Callers don't care | |
| 18 | + * which backend is active; the framework wires the right one | |
| 19 | + * based on `vibeerp.files.backend` in config. v1.0 ships the | |
| 20 | + * local-disk backend; the S3 backend lands as an additive change | |
| 21 | + * to `platform-files` without touching this interface. | |
| 22 | + * - **Streaming, not byte arrays, for large payloads.** The | |
| 23 | + * [get] method returns an [InputStream] so a caller can pipe a | |
| 24 | + * multi-MB report PDF straight to an HTTP response body without | |
| 25 | + * materializing the whole thing in memory. Small-payload callers | |
| 26 | + * can still use `.readAllBytes()`. | |
| 27 | + * - **Keys are opaque strings.** The caller picks the key | |
| 28 | + * ("reports/quote-2026-0001.pdf", "plugin-printingshop/plate- | |
| 29 | + * attachments/PLATE-042.png") and the store treats it as an | |
| 30 | + * opaque identifier. Slashes are allowed and are mapped to | |
| 31 | + * subdirectories by the local-disk backend. | |
| 32 | + * - **Content-type is a first-class attribute.** Every file carries | |
| 33 | + * its declared MIME type so a downstream HTTP surface can serve | |
| 34 | + * it with the correct `Content-Type` header without guessing | |
| 35 | + * from the key's extension. | |
| 36 | + * | |
| 37 | + * **What this facade does NOT expose:** | |
| 38 | + * - Random access / range reads. `get()` streams from the start. | |
| 39 | + * Range requests are a future enhancement when a real consumer | |
| 40 | + * needs them (e.g. video streaming in a quality inspection | |
| 41 | + * attachment). | |
| 42 | + * - Directory-level operations. The store is flat key→blob; | |
| 43 | + * [list] with a prefix is the approximation. | |
| 44 | + * - Atomic rename. `put()` of an existing key overwrites the old | |
| 45 | + * content; there is no "move" or "copy" verb. | |
| 46 | + * | |
| 47 | + * **Why a facade instead of Spring's `ResourceLoader`:** plug-ins | |
| 48 | + * must not import Spring types (CLAUDE.md guardrail #10). Building | |
| 49 | + * a small vibe-erp-shaped interface decouples plug-in code from the | |
| 50 | + * Spring ecosystem and leaves the door open to swap backends. | |
| 51 | + */ | |
| 52 | +public interface FileStorage { | |
| 53 | + | |
| 54 | + /** | |
| 55 | + * Store [content] under [key]. Overwrites any existing file | |
| 56 | + * with the same key. Returns a [FileHandle] describing the | |
| 57 | + * stored file (size, content type, created timestamp) so the | |
| 58 | + * caller can immediately show it in a UI or log the outcome. | |
| 59 | + * | |
| 60 | + * The provided [InputStream] is read to completion but NOT | |
| 61 | + * closed by this method — the caller owns its lifecycle and | |
| 62 | + * should close it in a `use { }` block. The implementation | |
| 63 | + * copies bytes into its own buffer; the stream does not need | |
| 64 | + * to support marking or seeking. | |
| 65 | + * | |
| 66 | + * @throws IllegalArgumentException if the key is blank or | |
| 67 | + * contains disallowed path segments (e.g. `..`). | |
| 68 | + */ | |
| 69 | + public fun put(key: String, contentType: String, content: InputStream): FileHandle | |
| 70 | + | |
| 71 | + /** | |
| 72 | + * Read the file at [key]. Returns a [FileReadResult] containing | |
| 73 | + * the metadata AND an open [InputStream] the caller MUST close | |
| 74 | + * (use `result.content.use { ... }`). Returns `null` if no file | |
| 75 | + * exists at [key]. | |
| 76 | + */ | |
| 77 | + public fun get(key: String): FileReadResult? | |
| 78 | + | |
| 79 | + /** | |
| 80 | + * Check whether a file exists at [key]. Faster than `get(...)` | |
| 81 | + * for the "does this attachment still exist?" case because it | |
| 82 | + * doesn't open an InputStream. | |
| 83 | + */ | |
| 84 | + public fun exists(key: String): Boolean | |
| 85 | + | |
| 86 | + /** | |
| 87 | + * Delete the file at [key]. Returns true if a file was deleted, | |
| 88 | + * false if no file existed. Idempotent — deleting a | |
| 89 | + * non-existent key is a successful no-op. | |
| 90 | + */ | |
| 91 | + public fun delete(key: String): Boolean | |
| 92 | + | |
| 93 | + /** | |
| 94 | + * List every file whose key starts with [prefix]. An empty | |
| 95 | + * prefix returns every file. Results are sorted by key for | |
| 96 | + * determinism. | |
| 97 | + */ | |
| 98 | + public fun list(prefix: String): List<FileHandle> | |
| 99 | +} | |
| 100 | + | |
| 101 | +/** | |
| 102 | + * Metadata describing a stored file. | |
| 103 | + * | |
| 104 | + * Returned by [FileStorage.put] and [FileStorage.list], and | |
| 105 | + * embedded in [FileReadResult]. Carries everything a caller needs | |
| 106 | + * to display the file (size for a progress bar, content type for | |
| 107 | + * rendering) without forcing a re-read. | |
| 108 | + */ | |
| 109 | +public data class FileHandle( | |
| 110 | + public val key: String, | |
| 111 | + public val size: Long, | |
| 112 | + public val contentType: String, | |
| 113 | + public val createdAt: Instant, | |
| 114 | + public val updatedAt: Instant, | |
| 115 | +) | |
| 116 | + | |
| 117 | +/** | |
| 118 | + * Result of a successful [FileStorage.get] call. The caller MUST | |
| 119 | + * close [content] after reading; `result.content.use { ... }` is | |
| 120 | + * the idiomatic shape. | |
| 121 | + */ | |
| 122 | +public data class FileReadResult( | |
| 123 | + public val handle: FileHandle, | |
| 124 | + public val content: InputStream, | |
| 125 | +) | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt
| ... | ... | @@ -4,6 +4,7 @@ import org.vibeerp.api.v1.entity.EntityRegistry |
| 4 | 4 | import org.vibeerp.api.v1.event.EventBus |
| 5 | 5 | import org.vibeerp.api.v1.i18n.LocaleProvider |
| 6 | 6 | import org.vibeerp.api.v1.i18n.Translator |
| 7 | +import org.vibeerp.api.v1.files.FileStorage | |
| 7 | 8 | import org.vibeerp.api.v1.persistence.Transaction |
| 8 | 9 | import org.vibeerp.api.v1.security.PermissionCheck |
| 9 | 10 | import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar |
| ... | ... | @@ -112,6 +113,22 @@ interface PluginContext { |
| 112 | 113 | "PluginContext.taskHandlers is not implemented by this host. " + |
| 113 | 114 | "Upgrade vibe_erp to v0.7 or later." |
| 114 | 115 | ) |
| 116 | + | |
| 117 | + /** | |
| 118 | + * Binary-blob storage for the framework. Plug-ins use this to | |
| 119 | + * persist report PDFs, uploaded attachments, exported archives, | |
| 120 | + * or any other content that doesn't fit in a relational column. | |
| 121 | + * See [org.vibeerp.api.v1.files.FileStorage]. | |
| 122 | + * | |
| 123 | + * Added in api.v1.0.x (P1.9). Default implementation throws so | |
| 124 | + * an older host loading a newer plug-in fails loudly at first | |
| 125 | + * call rather than silently swallowing writes. | |
| 126 | + */ | |
| 127 | + val files: FileStorage | |
| 128 | + get() = throw UnsupportedOperationException( | |
| 129 | + "PluginContext.files is not implemented by this host. " + | |
| 130 | + "Upgrade vibe_erp to v0.8 or later." | |
| 131 | + ) | |
| 115 | 132 | } |
| 116 | 133 | |
| 117 | 134 | /** | ... | ... |
distribution/build.gradle.kts
| ... | ... | @@ -28,6 +28,7 @@ dependencies { |
| 28 | 28 | implementation(project(":platform:platform-i18n")) |
| 29 | 29 | implementation(project(":platform:platform-workflow")) |
| 30 | 30 | implementation(project(":platform:platform-jobs")) |
| 31 | + implementation(project(":platform:platform-files")) | |
| 31 | 32 | implementation(project(":pbc:pbc-identity")) |
| 32 | 33 | implementation(project(":pbc:pbc-catalog")) |
| 33 | 34 | implementation(project(":pbc:pbc-partners")) | ... | ... |
platform/platform-files/build.gradle.kts
0 → 100644
| 1 | +plugins { | |
| 2 | + alias(libs.plugins.kotlin.jvm) | |
| 3 | + alias(libs.plugins.kotlin.spring) | |
| 4 | + alias(libs.plugins.spring.dependency.management) | |
| 5 | +} | |
| 6 | + | |
| 7 | +description = "vibe_erp binary file store. Local-disk backend (v1.0) with seam for S3 (post-v1.0). INTERNAL." | |
| 8 | + | |
| 9 | +java { | |
| 10 | + toolchain { | |
| 11 | + languageVersion.set(JavaLanguageVersion.of(21)) | |
| 12 | + } | |
| 13 | +} | |
| 14 | + | |
| 15 | +kotlin { | |
| 16 | + jvmToolchain(21) | |
| 17 | + compilerOptions { | |
| 18 | + freeCompilerArgs.add("-Xjsr305=strict") | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +dependencies { | |
| 23 | + api(project(":api:api-v1")) | |
| 24 | + implementation(project(":platform:platform-security")) // @RequirePermission on the controller | |
| 25 | + | |
| 26 | + implementation(libs.kotlin.stdlib) | |
| 27 | + implementation(libs.kotlin.reflect) | |
| 28 | + implementation(libs.jackson.module.kotlin) | |
| 29 | + | |
| 30 | + implementation(libs.spring.boot.starter) | |
| 31 | + implementation(libs.spring.boot.starter.web) | |
| 32 | + | |
| 33 | + testImplementation(libs.spring.boot.starter.test) | |
| 34 | + testImplementation(libs.junit.jupiter) | |
| 35 | + testImplementation(libs.assertk) | |
| 36 | + testImplementation(libs.mockk) | |
| 37 | +} | |
| 38 | + | |
| 39 | +tasks.test { | |
| 40 | + useJUnitPlatform() | |
| 41 | +} | ... | ... |
platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/LocalDiskFileStorage.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.stereotype.Component | |
| 6 | +import org.vibeerp.api.v1.files.FileHandle | |
| 7 | +import org.vibeerp.api.v1.files.FileReadResult | |
| 8 | +import org.vibeerp.api.v1.files.FileStorage | |
| 9 | +import java.io.ByteArrayInputStream | |
| 10 | +import java.io.InputStream | |
| 11 | +import java.nio.file.Files | |
| 12 | +import java.nio.file.LinkOption | |
| 13 | +import java.nio.file.Path | |
| 14 | +import java.nio.file.Paths | |
| 15 | +import java.nio.file.StandardCopyOption | |
| 16 | +import java.nio.file.attribute.FileTime | |
| 17 | +import java.time.Instant | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * Local-disk implementation of api.v1 [FileStorage]. | |
| 21 | + * | |
| 22 | + * **Layout.** Files live under the directory configured by | |
| 23 | + * `vibeerp.files.local-path` (default `/opt/vibe-erp/files`, | |
| 24 | + * overridden in the dev profile to `./files-dev`). Keys map | |
| 25 | + * directly to relative paths: a key `reports/quote-2026-0001.pdf` | |
| 26 | + * becomes a file at `<root>/reports/quote-2026-0001.pdf`. The | |
| 27 | + * backend creates intermediate directories on `put` as needed. | |
| 28 | + * | |
| 29 | + * **Metadata storage.** Each blob lives in two files: | |
| 30 | + * - `<key>` — the raw bytes | |
| 31 | + * - `<key>.meta` — a tiny text file with one line: | |
| 32 | + * `content_type=<value>` | |
| 33 | + * | |
| 34 | + * A sidecar file is simpler than an xattr filesystem extended | |
| 35 | + * attribute (which are not portable across Linux/macOS) and | |
| 36 | + * simpler than embedding metadata in a SQLite or H2 index. For | |
| 37 | + * single-tenant per-instance deployments it's the right weight. | |
| 38 | + * The S3 backend, when it lands, uses native S3 object metadata | |
| 39 | + * instead. | |
| 40 | + * | |
| 41 | + * **Key safety.** The backend refuses any key containing `..` or | |
| 42 | + * a leading `/` — both escape-the-root attacks. It also refuses | |
| 43 | + * any key ending in `.meta` to avoid collisions with the sidecar | |
| 44 | + * metadata files. Every `put` call validates the resolved path | |
| 45 | + * stays under the configured root, matching the convention the | |
| 46 | + * MetadataLoader uses for plug-in JAR paths. | |
| 47 | + */ | |
| 48 | +@Component | |
| 49 | +class LocalDiskFileStorage( | |
| 50 | + @Value("\${vibeerp.files.local-path:./files-local}") | |
| 51 | + rootPath: String, | |
| 52 | +) : FileStorage { | |
| 53 | + | |
| 54 | + private val log = LoggerFactory.getLogger(LocalDiskFileStorage::class.java) | |
| 55 | + private val root: Path = Paths.get(rootPath).toAbsolutePath().normalize() | |
| 56 | + | |
| 57 | + init { | |
| 58 | + Files.createDirectories(root) | |
| 59 | + log.info("LocalDiskFileStorage root = {}", root) | |
| 60 | + } | |
| 61 | + | |
| 62 | + override fun put(key: String, contentType: String, content: InputStream): FileHandle { | |
| 63 | + validateKey(key) | |
| 64 | + require(contentType.isNotBlank()) { "contentType must not be blank" } | |
| 65 | + | |
| 66 | + val target = resolve(key) | |
| 67 | + Files.createDirectories(target.parent) | |
| 68 | + | |
| 69 | + // Write to a temp file then atomically move, so a concurrent | |
| 70 | + // read against the same key either sees the old content or | |
| 71 | + // the new, never a half-written mix. NOT cross-process safe | |
| 72 | + // beyond what the underlying filesystem provides, which is | |
| 73 | + // fine for single-instance deployments. | |
| 74 | + val tmp = target.resolveSibling("${target.fileName}.${System.nanoTime()}.tmp") | |
| 75 | + Files.newOutputStream(tmp).use { out -> | |
| 76 | + content.copyTo(out) | |
| 77 | + } | |
| 78 | + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) | |
| 79 | + | |
| 80 | + val metaPath = metaPath(target) | |
| 81 | + Files.writeString(metaPath, "content_type=$contentType\n") | |
| 82 | + | |
| 83 | + val now = Instant.now() | |
| 84 | + Files.setLastModifiedTime(target, FileTime.from(now)) | |
| 85 | + | |
| 86 | + val handle = buildHandle(key, target, contentType, createdAt = now, updatedAt = now) | |
| 87 | + log.debug("put key='{}' size={} contentType={}", key, handle.size, contentType) | |
| 88 | + return handle | |
| 89 | + } | |
| 90 | + | |
| 91 | + override fun get(key: String): FileReadResult? { | |
| 92 | + validateKey(key) | |
| 93 | + val target = resolve(key) | |
| 94 | + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) return null | |
| 95 | + | |
| 96 | + val contentType = readMetaContentType(target) ?: DEFAULT_CONTENT_TYPE | |
| 97 | + val lastModified = Files.getLastModifiedTime(target).toInstant() | |
| 98 | + val handle = buildHandle(key, target, contentType, createdAt = lastModified, updatedAt = lastModified) | |
| 99 | + val stream = Files.newInputStream(target) | |
| 100 | + return FileReadResult(handle = handle, content = stream) | |
| 101 | + } | |
| 102 | + | |
| 103 | + override fun exists(key: String): Boolean { | |
| 104 | + validateKey(key) | |
| 105 | + val target = resolve(key) | |
| 106 | + return Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) | |
| 107 | + } | |
| 108 | + | |
| 109 | + override fun delete(key: String): Boolean { | |
| 110 | + validateKey(key) | |
| 111 | + val target = resolve(key) | |
| 112 | + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) return false | |
| 113 | + Files.deleteIfExists(target) | |
| 114 | + Files.deleteIfExists(metaPath(target)) | |
| 115 | + log.debug("delete key='{}'", key) | |
| 116 | + return true | |
| 117 | + } | |
| 118 | + | |
| 119 | + override fun list(prefix: String): List<FileHandle> { | |
| 120 | + if (!Files.exists(root)) return emptyList() | |
| 121 | + val result = mutableListOf<FileHandle>() | |
| 122 | + Files.walk(root).use { stream -> | |
| 123 | + stream.forEach { path -> | |
| 124 | + if (!Files.isRegularFile(path)) return@forEach | |
| 125 | + val fileName = path.fileName.toString() | |
| 126 | + if (fileName.endsWith(META_SUFFIX)) return@forEach | |
| 127 | + val relative = root.relativize(path).toString().replace('\\', '/') | |
| 128 | + if (!relative.startsWith(prefix)) return@forEach | |
| 129 | + val contentType = readMetaContentType(path) ?: DEFAULT_CONTENT_TYPE | |
| 130 | + val lastModified = Files.getLastModifiedTime(path).toInstant() | |
| 131 | + result += buildHandle(relative, path, contentType, createdAt = lastModified, updatedAt = lastModified) | |
| 132 | + } | |
| 133 | + } | |
| 134 | + return result.sortedBy { it.key } | |
| 135 | + } | |
| 136 | + | |
| 137 | + // ─── internals ───────────────────────────────────────────────── | |
| 138 | + | |
| 139 | + private fun validateKey(key: String) { | |
| 140 | + require(key.isNotBlank()) { "file key must not be blank" } | |
| 141 | + require(!key.startsWith("/")) { "file key must not start with '/' (got '$key')" } | |
| 142 | + require(!key.contains("..")) { "file key must not contain '..' (got '$key')" } | |
| 143 | + require(!key.endsWith(META_SUFFIX)) { | |
| 144 | + "file key must not end with '$META_SUFFIX' (reserved for metadata sidecar)" | |
| 145 | + } | |
| 146 | + } | |
| 147 | + | |
| 148 | + private fun resolve(key: String): Path { | |
| 149 | + val resolved = root.resolve(key).normalize() | |
| 150 | + require(resolved.startsWith(root)) { | |
| 151 | + "file key '$key' resolves outside the configured root" | |
| 152 | + } | |
| 153 | + return resolved | |
| 154 | + } | |
| 155 | + | |
| 156 | + private fun metaPath(target: Path): Path = | |
| 157 | + target.resolveSibling(target.fileName.toString() + META_SUFFIX) | |
| 158 | + | |
| 159 | + private fun readMetaContentType(target: Path): String? { | |
| 160 | + val meta = metaPath(target) | |
| 161 | + if (!Files.isRegularFile(meta)) return null | |
| 162 | + val line = Files.readAllLines(meta).firstOrNull() ?: return null | |
| 163 | + return line.removePrefix("content_type=").ifBlank { null } | |
| 164 | + } | |
| 165 | + | |
| 166 | + private fun buildHandle( | |
| 167 | + key: String, | |
| 168 | + target: Path, | |
| 169 | + contentType: String, | |
| 170 | + createdAt: Instant, | |
| 171 | + updatedAt: Instant, | |
| 172 | + ): FileHandle = FileHandle( | |
| 173 | + key = key, | |
| 174 | + size = Files.size(target), | |
| 175 | + contentType = contentType, | |
| 176 | + createdAt = createdAt, | |
| 177 | + updatedAt = updatedAt, | |
| 178 | + ) | |
| 179 | + | |
| 180 | + /** | |
| 181 | + * Test-only helper: read the file's entire content as a byte | |
| 182 | + * array. Useful for unit tests that want to assert round-trip | |
| 183 | + * correctness without having to close an InputStream manually. | |
| 184 | + */ | |
| 185 | + internal fun getBytes(key: String): ByteArray? { | |
| 186 | + val result = get(key) ?: return null | |
| 187 | + return result.content.use { it.readBytes() } | |
| 188 | + } | |
| 189 | + | |
| 190 | + companion object { | |
| 191 | + private const val META_SUFFIX: String = ".meta" | |
| 192 | + private const val DEFAULT_CONTENT_TYPE: String = "application/octet-stream" | |
| 193 | + } | |
| 194 | +} | ... | ... |
platform/platform-files/src/main/kotlin/org/vibeerp/platform/files/http/FileController.kt
0 → 100644
| 1 | +package org.vibeerp.platform.files.http | |
| 2 | + | |
| 3 | +import org.springframework.core.io.InputStreamResource | |
| 4 | +import org.springframework.http.HttpHeaders | |
| 5 | +import org.springframework.http.HttpStatus | |
| 6 | +import org.springframework.http.MediaType | |
| 7 | +import org.springframework.http.ResponseEntity | |
| 8 | +import org.springframework.web.bind.annotation.DeleteMapping | |
| 9 | +import org.springframework.web.bind.annotation.ExceptionHandler | |
| 10 | +import org.springframework.web.bind.annotation.GetMapping | |
| 11 | +import org.springframework.web.bind.annotation.PostMapping | |
| 12 | +import org.springframework.web.bind.annotation.RequestMapping | |
| 13 | +import org.springframework.web.bind.annotation.RequestParam | |
| 14 | +import org.springframework.web.bind.annotation.RestController | |
| 15 | +import org.springframework.web.multipart.MultipartFile | |
| 16 | +import org.vibeerp.api.v1.files.FileHandle | |
| 17 | +import org.vibeerp.api.v1.files.FileStorage | |
| 18 | +import org.vibeerp.platform.security.authz.RequirePermission | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * HTTP surface over the framework's [FileStorage]. | |
| 22 | + * | |
| 23 | + * - `POST /api/v1/files` multipart upload (form field `file`, query param `key`) | |
| 24 | + * - `GET /api/v1/files/metadata?key=...` read a file's metadata only | |
| 25 | + * - `GET /api/v1/files/download?key=...` stream the file's bytes with its Content-Type | |
| 26 | + * - `DELETE /api/v1/files?key=...` delete by key | |
| 27 | + * - `GET /api/v1/files?prefix=...` list by prefix | |
| 28 | + * | |
| 29 | + * The `key` is a query parameter (not a path variable) because keys | |
| 30 | + * contain slashes for logical subdirectories, and Spring's default | |
| 31 | + * path-variable matching would collapse them. A query param keeps | |
| 32 | + * the routing trivial and the key opaque. | |
| 33 | + */ | |
| 34 | +@RestController | |
| 35 | +@RequestMapping("/api/v1/files") | |
| 36 | +class FileController( | |
| 37 | + private val fileStorage: FileStorage, | |
| 38 | +) { | |
| 39 | + | |
| 40 | + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) | |
| 41 | + @RequirePermission("files.file.write") | |
| 42 | + fun upload( | |
| 43 | + @RequestParam("key") key: String, | |
| 44 | + @RequestParam("file") file: MultipartFile, | |
| 45 | + ): ResponseEntity<FileHandle> { | |
| 46 | + val contentType = file.contentType ?: MediaType.APPLICATION_OCTET_STREAM_VALUE | |
| 47 | + val handle = file.inputStream.use { stream -> | |
| 48 | + fileStorage.put(key = key, contentType = contentType, content = stream) | |
| 49 | + } | |
| 50 | + return ResponseEntity.status(HttpStatus.CREATED).body(handle) | |
| 51 | + } | |
| 52 | + | |
| 53 | + @GetMapping(params = ["prefix"]) | |
| 54 | + @RequirePermission("files.file.read") | |
| 55 | + fun list(@RequestParam("prefix", defaultValue = "") prefix: String): List<FileHandle> = | |
| 56 | + fileStorage.list(prefix) | |
| 57 | + | |
| 58 | + @GetMapping("/metadata") | |
| 59 | + @RequirePermission("files.file.read") | |
| 60 | + fun metadata(@RequestParam("key") key: String): ResponseEntity<FileHandle> { | |
| 61 | + val result = fileStorage.get(key) ?: return ResponseEntity.notFound().build() | |
| 62 | + // Metadata-only endpoint: close the InputStream the facade | |
| 63 | + // opened for us, we don't need the bytes here. | |
| 64 | + result.content.use { } | |
| 65 | + return ResponseEntity.ok(result.handle) | |
| 66 | + } | |
| 67 | + | |
| 68 | + @GetMapping("/download") | |
| 69 | + @RequirePermission("files.file.read") | |
| 70 | + fun download(@RequestParam("key") key: String): ResponseEntity<InputStreamResource> { | |
| 71 | + val result = fileStorage.get(key) ?: return ResponseEntity.notFound().build() | |
| 72 | + val resource = InputStreamResource(result.content) | |
| 73 | + return ResponseEntity.status(HttpStatus.OK) | |
| 74 | + .header(HttpHeaders.CONTENT_TYPE, result.handle.contentType) | |
| 75 | + .header(HttpHeaders.CONTENT_LENGTH, result.handle.size.toString()) | |
| 76 | + .header( | |
| 77 | + HttpHeaders.CONTENT_DISPOSITION, | |
| 78 | + "attachment; filename=\"${result.handle.key.substringAfterLast('/')}\"", | |
| 79 | + ) | |
| 80 | + .body(resource) | |
| 81 | + } | |
| 82 | + | |
| 83 | + @DeleteMapping | |
| 84 | + @RequirePermission("files.file.write") | |
| 85 | + fun delete(@RequestParam("key") key: String): ResponseEntity<DeleteResponse> { | |
| 86 | + val removed = fileStorage.delete(key) | |
| 87 | + return if (removed) { | |
| 88 | + ResponseEntity.ok(DeleteResponse(key = key, removed = true)) | |
| 89 | + } else { | |
| 90 | + ResponseEntity.status(HttpStatus.NOT_FOUND) | |
| 91 | + .body(DeleteResponse(key = key, removed = false)) | |
| 92 | + } | |
| 93 | + } | |
| 94 | + | |
| 95 | + @ExceptionHandler(IllegalArgumentException::class) | |
| 96 | + fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity<ErrorResponse> = | |
| 97 | + ResponseEntity.status(HttpStatus.BAD_REQUEST) | |
| 98 | + .body(ErrorResponse(message = ex.message ?: "bad request")) | |
| 99 | +} | |
| 100 | + | |
| 101 | +data class DeleteResponse( | |
| 102 | + val key: String, | |
| 103 | + val removed: Boolean, | |
| 104 | +) | |
| 105 | + | |
| 106 | +data class ErrorResponse( | |
| 107 | + val message: String, | |
| 108 | +) | ... | ... |
platform/platform-files/src/main/resources/META-INF/vibe-erp/metadata/files.yml
0 → 100644
| 1 | +# platform-files metadata. | |
| 2 | +# | |
| 3 | +# Loaded at boot by MetadataLoader, tagged source='core'. | |
| 4 | + | |
| 5 | +permissions: | |
| 6 | + - key: files.file.read | |
| 7 | + description: Read files from the framework binary store (download + list + metadata) | |
| 8 | + - key: files.file.write | |
| 9 | + description: Upload and delete files in the framework binary store | |
| 10 | + | |
| 11 | +menus: | |
| 12 | + - path: /files | |
| 13 | + label: Files | |
| 14 | + icon: folder | |
| 15 | + section: Files | |
| 16 | + order: 900 | ... | ... |
platform/platform-files/src/test/kotlin/org/vibeerp/platform/files/LocalDiskFileStorageTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.files | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.contains | |
| 6 | +import assertk.assertions.isEqualTo | |
| 7 | +import assertk.assertions.isFalse | |
| 8 | +import assertk.assertions.isInstanceOf | |
| 9 | +import assertk.assertions.isNotNull | |
| 10 | +import assertk.assertions.isNull | |
| 11 | +import assertk.assertions.isTrue | |
| 12 | +import org.junit.jupiter.api.Test | |
| 13 | +import org.junit.jupiter.api.io.TempDir | |
| 14 | +import java.io.ByteArrayInputStream | |
| 15 | +import java.nio.file.Path | |
| 16 | + | |
| 17 | +class LocalDiskFileStorageTest { | |
| 18 | + | |
| 19 | + private fun store(@TempDir tmp: Path): LocalDiskFileStorage = | |
| 20 | + LocalDiskFileStorage(rootPath = tmp.toString()) | |
| 21 | + | |
| 22 | + private fun bytes(s: String) = s.toByteArray(Charsets.UTF_8) | |
| 23 | + | |
| 24 | + @Test | |
| 25 | + fun `put then get round-trips content and metadata`(@TempDir tmp: Path) { | |
| 26 | + val storage = store(tmp) | |
| 27 | + val handle = storage.put("reports/quote.pdf", "application/pdf", ByteArrayInputStream(bytes("hello world"))) | |
| 28 | + | |
| 29 | + assertThat(handle.key).isEqualTo("reports/quote.pdf") | |
| 30 | + assertThat(handle.size).isEqualTo(11L) | |
| 31 | + assertThat(handle.contentType).isEqualTo("application/pdf") | |
| 32 | + | |
| 33 | + val back = storage.getBytes("reports/quote.pdf") | |
| 34 | + assertThat(back).isNotNull() | |
| 35 | + assertThat(String(back!!, Charsets.UTF_8)).isEqualTo("hello world") | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Test | |
| 39 | + fun `put overwrites an existing key with the new content`(@TempDir tmp: Path) { | |
| 40 | + val storage = store(tmp) | |
| 41 | + storage.put("k", "text/plain", ByteArrayInputStream(bytes("v1"))) | |
| 42 | + val handle = storage.put("k", "text/plain", ByteArrayInputStream(bytes("v2-longer"))) | |
| 43 | + | |
| 44 | + assertThat(handle.size).isEqualTo(9L) | |
| 45 | + assertThat(String(storage.getBytes("k")!!, Charsets.UTF_8)).isEqualTo("v2-longer") | |
| 46 | + } | |
| 47 | + | |
| 48 | + @Test | |
| 49 | + fun `get returns null for an unknown key`(@TempDir tmp: Path) { | |
| 50 | + val storage = store(tmp) | |
| 51 | + assertThat(storage.get("never/written.txt")).isNull() | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Test | |
| 55 | + fun `exists distinguishes present from absent without opening a stream`(@TempDir tmp: Path) { | |
| 56 | + val storage = store(tmp) | |
| 57 | + storage.put("present.txt", "text/plain", ByteArrayInputStream(bytes("x"))) | |
| 58 | + assertThat(storage.exists("present.txt")).isTrue() | |
| 59 | + assertThat(storage.exists("absent.txt")).isFalse() | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Test | |
| 63 | + fun `delete removes the file and its metadata sidecar`(@TempDir tmp: Path) { | |
| 64 | + val storage = store(tmp) | |
| 65 | + storage.put("to-delete.txt", "text/plain", ByteArrayInputStream(bytes("bye"))) | |
| 66 | + assertThat(storage.delete("to-delete.txt")).isTrue() | |
| 67 | + assertThat(storage.exists("to-delete.txt")).isFalse() | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Test | |
| 71 | + fun `delete on unknown key returns false`(@TempDir tmp: Path) { | |
| 72 | + val storage = store(tmp) | |
| 73 | + assertThat(storage.delete("never.txt")).isFalse() | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + fun `list filters by prefix and returns sorted keys`(@TempDir tmp: Path) { | |
| 78 | + val storage = store(tmp) | |
| 79 | + storage.put("reports/a.pdf", "application/pdf", ByteArrayInputStream(bytes("A"))) | |
| 80 | + storage.put("reports/b.pdf", "application/pdf", ByteArrayInputStream(bytes("BB"))) | |
| 81 | + storage.put("attachments/x.png", "image/png", ByteArrayInputStream(bytes("X"))) | |
| 82 | + | |
| 83 | + val reports = storage.list("reports/") | |
| 84 | + assertThat(reports.size).isEqualTo(2) | |
| 85 | + assertThat(reports[0].key).isEqualTo("reports/a.pdf") | |
| 86 | + assertThat(reports[1].key).isEqualTo("reports/b.pdf") | |
| 87 | + | |
| 88 | + val all = storage.list("") | |
| 89 | + assertThat(all.size).isEqualTo(3) | |
| 90 | + } | |
| 91 | + | |
| 92 | + @Test | |
| 93 | + fun `put rejects a key with dot-dot`(@TempDir tmp: Path) { | |
| 94 | + val storage = store(tmp) | |
| 95 | + assertFailure { | |
| 96 | + storage.put("../etc/passwd", "text/plain", ByteArrayInputStream(bytes("x"))) | |
| 97 | + }.isInstanceOf(IllegalArgumentException::class) | |
| 98 | + } | |
| 99 | + | |
| 100 | + @Test | |
| 101 | + fun `put rejects a key starting with slash`(@TempDir tmp: Path) { | |
| 102 | + val storage = store(tmp) | |
| 103 | + assertFailure { | |
| 104 | + storage.put("/absolute", "text/plain", ByteArrayInputStream(bytes("x"))) | |
| 105 | + }.isInstanceOf(IllegalArgumentException::class) | |
| 106 | + } | |
| 107 | + | |
| 108 | + @Test | |
| 109 | + fun `put rejects a key ending in dot-meta sidecar`(@TempDir tmp: Path) { | |
| 110 | + val storage = store(tmp) | |
| 111 | + assertFailure { | |
| 112 | + storage.put("foo.meta", "text/plain", ByteArrayInputStream(bytes("x"))) | |
| 113 | + }.isInstanceOf(IllegalArgumentException::class) | |
| 114 | + } | |
| 115 | + | |
| 116 | + @Test | |
| 117 | + fun `put rejects blank content type`(@TempDir tmp: Path) { | |
| 118 | + val storage = store(tmp) | |
| 119 | + assertFailure { | |
| 120 | + storage.put("k", " ", ByteArrayInputStream(bytes("x"))) | |
| 121 | + }.isInstanceOf(IllegalArgumentException::class) | |
| 122 | + } | |
| 123 | + | |
| 124 | + @Test | |
| 125 | + fun `list sidecar metadata files are hidden from listing results`(@TempDir tmp: Path) { | |
| 126 | + val storage = store(tmp) | |
| 127 | + storage.put("a.txt", "text/plain", ByteArrayInputStream(bytes("A"))) | |
| 128 | + val keys = storage.list("").map { it.key } | |
| 129 | + // Exactly one entry — the sidecar "a.txt.meta" is suppressed | |
| 130 | + assertThat(keys.size).isEqualTo(1) | |
| 131 | + assertThat(keys[0]).isEqualTo("a.txt") | |
| 132 | + } | |
| 133 | +} | ... | ... |
settings.gradle.kts
| ... | ... | @@ -48,6 +48,9 @@ project(":platform:platform-workflow").projectDir = file("platform/platform-work |
| 48 | 48 | include(":platform:platform-jobs") |
| 49 | 49 | project(":platform:platform-jobs").projectDir = file("platform/platform-jobs") |
| 50 | 50 | |
| 51 | +include(":platform:platform-files") | |
| 52 | +project(":platform:platform-files").projectDir = file("platform/platform-files") | |
| 53 | + | |
| 51 | 54 | // ─── Packaged Business Capabilities (core PBCs) ───────────────────── |
| 52 | 55 | include(":pbc:pbc-identity") |
| 53 | 56 | project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") | ... | ... |