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.