Commit f2156c5b6218fb2d2f1d53b78ee771dc284457bf

Authored by zichun
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.
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(&quot;:platform:platform-workflow&quot;).projectDir = file(&quot;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")
... ...