-
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.