# File storage

> Upload, store and serve files through the S3-compatible storage abstraction (MinIO locally).

## One seam, two backends

File storage hides behind a single interface, `IFileStorage`. The implementation talks to an
S3-compatible bucket: real **AWS S3** in production, **MinIO** on your machine. The same `AWSSDK.S3`
client backs both, so only configuration changes between environments, never your code.

```
api/src/Slicekit.Core/
  Domain/Services/
    IFileStorage.cs          // the seam: interface plus the record types
  Infrastructure/Storage/
    S3FileStorage.cs         // S3-backed implementation, internal
    StorageSettings.cs       // bound config, validated at startup
```

`IFileStorage` lives in `Slicekit.Core/Domain/Services/IFileStorage.cs`. The implementation is
`internal`, so handlers depend only on the interface. No HTTP endpoint ships: the upload shape is
application-specific, so you wire your own slice (see [Adding a vertical slice](/docs/vertical-slices)).

## The interface

Every method returns `Result` (or `Result` for `DeleteAsync`), so failures arrive as `AppError`
rather than exceptions. The shared `ToProblem()` mapping turns those into ProblemDetails when you
surface them from an endpoint.

```csharp
namespace Slicekit.Core.Domain.Services;

public interface IFileStorage
{
    Task<Result> PutAsync(FileUpload file, CancellationToken ct = default);

    Task<IReadOnlyList<Result>> PutManyAsync(
        IReadOnlyList files, CancellationToken ct = default);

    Task<Result> GetAsync(string key, CancellationToken ct = default);

    Task DeleteAsync(string key, CancellationToken ct = default);

    Task<Result> GetPresignedUrlAsync(
        string key, TimeSpan? expiresIn = null, CancellationToken ct = default);
}
```

The record types it trades in:

```csharp
public sealed record FileUpload(string FileName, string ContentType, long Size, Stream Content);

public sealed record StoredFile(string Key, string ContentType, long Size);

public sealed record FileContent(Stream Content, string ContentType, long Size) : IAsyncDisposable;
```

| Method                                   | Returns                             | Notes                                                              |
| ---------------------------------------- | ----------------------------------- | ----------------------------------------------------------------- |
| `PutAsync(FileUpload)`                   | `Result`                | Validates size and content type. Failures: `ValidationError`, `InternalError`. |
| `PutManyAsync(IReadOnlyList)`| `IReadOnlyList<Result>` | One result per input, same order. One bad file does not fail the others. |
| `GetAsync(key)`                          | `Result`               | `NotFoundError` when the key is missing. `FileContent` is `IAsyncDisposable`. |
| `DeleteAsync(key)`                       | `Result`                            | Idempotent at the S3 layer: missing keys still succeed.           |
| `GetPresignedUrlAsync(key, expiresIn?)`  | `Result`                       | Time-limited GET URL. Defaults to `PresignedUrlDefaultMinutes`.   |

## Uploading from a handler

Inject `IFileStorage` and call `PutAsync`. A single upload is one line:

```csharp
public sealed class UploadAvatarHandler(IFileStorage files)
{
    public async Task<Result> HandleAsync(FileUpload upload, CancellationToken ct) =>
        await files.PutAsync(upload, ct);
}
```

Batch uploads use `PutManyAsync`. The batch never aborts: each input gets its own `Result`
in input order, so one oversize file does not sink the rest.

```csharp
public sealed class UploadAttachmentsHandler(IFileStorage files)
{
    public async Task<IReadOnlyList<Result>> HandleAsync(
        IReadOnlyList uploads, CancellationToken ct) =>
        await files.PutManyAsync(uploads, ct);
}
```

Failures come back as `AppError`: `ValidationError` for an oversize or disallowed content type,
`InternalError` for a storage exception. Map them with `result.Error.ToProblem()` in your endpoint
(see [Result and error handling](/docs/error-handling)).

### Keys are generated, never chosen

You do not pass a key into `PutAsync`. The storage layer generates one server-side as
`yyyy/MM/dd/<guidv7>.<ext>` and returns it on `StoredFile.Key`. The original filename is preserved in
S3 metadata under `original-filename` (URL-encoded). Generating the key avoids reflecting untrusted
input into the object path and gives you prefix-listable date partitions for free. Persist the
returned key against your domain row; that is the handle you store and download by later.

## Serving and downloading

There are two ways to get bytes back out, and the choice is about who streams them.

Stream through your API when you want to gate the download behind a permission check. `GetAsync`
returns a `FileContent`, which is `IAsyncDisposable`, so `await using` it while you stream:

```csharp
public sealed class ServeAttachmentHandler(IFileStorage files)
{
    public async Task<Result> HandleAsync(string key, CancellationToken ct) =>
        await files.GetAsync(key, ct);   // NotFoundError when the key is missing
}
```

Hand out a presigned URL when the bytes do not need to pass through your server. The browser then
fetches the object straight from S3, with no server round-trip for the payload:

```csharp
var result = await files.GetPresignedUrlAsync(key);                          // default expiry
var result = await files.GetPresignedUrlAsync(key, TimeSpan.FromHours(1));   // explicit expiry
```

Deleting is idempotent: a missing key still succeeds, so you can call it without a prior existence
check.

```csharp
var result = await files.DeleteAsync(key, ct);
```

### Accepting the upload over HTTP

No upload UI ships in the template. When you add an endpoint that builds a `FileUpload` from an
`IFormFile`, the matching frontend slice POSTs `multipart/form-data` through the `apiFetch` wrapper.
Pass the `FormData` through unmodified: do not set `Content-Type` yourself, because the browser fills
in the multipart boundary for you.

## Configuration

Settings bind from the `Storage:` section and validate at startup via `ValidateOnStart()`. Every key
has a matching env-var override (see [Configuration](/docs/configuration)).

| Setting                              | Env var                                | Default            | Notes                                                       |
| ------------------------------------ | -------------------------------------- | ------------------ | ----------------------------------------------------------- |
| `Storage:Bucket`                     | `Storage__Bucket`                      | _required_         | Bucket the SDK targets.                                     |
| `Storage:Region`                     | `Storage__Region`                      | `us-east-1`        | Real AWS S3 region. MinIO ignores it, but a value is needed. |
| `Storage:ServiceUrl`                 | `Storage__ServiceUrl`                  | _empty_            | Empty means real AWS S3. Set it for MinIO or another S3-compatible host. |
| `Storage:AccessKey`                  | `Storage__AccessKey`                   | _empty_            | Empty plus real AWS uses the default credential chain.      |
| `Storage:SecretKey`                  | `Storage__SecretKey`                   | _empty_            |                                                             |
| `Storage:MaxFileSizeBytes`           | `Storage__MaxFileSizeBytes`            | `26214400` (25 MB) | Per-file cap. Oversize files come back as `ValidationError`. |
| `Storage:MaxFilesPerRequest`         | `Storage__MaxFilesPerRequest`          | `20`               | Soft cap surfaced to callers. Enforce it at your endpoint.  |
| `Storage:AllowedContentTypes`        | `Storage__AllowedContentTypes__0`, etc.| `[]`               | Empty allows any content type. Otherwise an allow-list.     |
| `Storage:PresignedUrlDefaultMinutes` | `Storage__PresignedUrlDefaultMinutes`  | `15`               | Default expiry when `GetPresignedUrlAsync` is called without an explicit window. |

A few rules the DI layer enforces so operators only ever set the URL:

- `ServiceUrl` and `ForcePathStyle` are coupled. When a `ServiceUrl` is configured the client flips to
  path-style addressing automatically, and switches to `http://` when the URL is plaintext.
- Cross-field validation (`StorageSettings.Validate`): when `ServiceUrl` is set, both `AccessKey` and
  `SecretKey` are required. The default AWS credential chain is only reachable when `ServiceUrl` is empty.

## Local development with MinIO

`docker compose up -d` brings up MinIO (port `9000` for the S3 API, `9001` for the console) plus a
one-shot `minio-bootstrap` container that creates the `slicekit-dev` bucket. `appsettings.json` already
points at MinIO with `minioadmin/minioadmin`, so a fresh checkout works with no env vars. See
[Getting started](/docs/getting-started) for the full first-run flow.

```sh
docker compose up -d minio minio-bootstrap
```

Open the console at <http://localhost:9001> and sign in with `minioadmin` / `minioadmin` to watch
objects land under `slicekit-dev/yyyy/MM/dd/<guid>.<ext>`.

## Production

Fill the `STORAGE_*` block in `.env.prod`. Three common shapes:

- **Real AWS S3 with an IAM role** (recommended): leave `STORAGE_SERVICE_URL`, `STORAGE_ACCESS_KEY`
  and `STORAGE_SECRET_KEY` blank. The SDK uses the default credential chain (IAM role, environment,
  `~/.aws`).
- **Real AWS S3 with static keys**: leave `STORAGE_SERVICE_URL` blank, set `STORAGE_ACCESS_KEY` and
  `STORAGE_SECRET_KEY`.
- **Self-hosted MinIO or another S3-compatible host**: set all three.

## Checklist

- [ ] `docker compose up -d minio minio-bootstrap` brings MinIO up and creates the `slicekit-dev` bucket.
- [ ] Inject `IFileStorage` into your handler and call `PutAsync` / `PutManyAsync`.
- [ ] Persist the returned `StoredFile.Key` against your domain row.
- [ ] Serve bytes with `GetAsync` (gated) or hand out a `GetPresignedUrlAsync` URL (direct).
- [ ] Map `result.Error.ToProblem()` for the `ValidationError` and `NotFoundError` cases.
- [ ] Confirm the object under `slicekit-dev/` in the MinIO console at <http://localhost:9001>.
