Winche.Storage.AspNetCore.Rest
6.0.0
See the version list below for details.
dotnet add package Winche.Storage.AspNetCore.Rest --version 6.0.0
NuGet\Install-Package Winche.Storage.AspNetCore.Rest -Version 6.0.0
<PackageReference Include="Winche.Storage.AspNetCore.Rest" Version="6.0.0" />
<PackageVersion Include="Winche.Storage.AspNetCore.Rest" Version="6.0.0" />
<PackageReference Include="Winche.Storage.AspNetCore.Rest" />
paket add Winche.Storage.AspNetCore.Rest --version 6.0.0
#r "nuget: Winche.Storage.AspNetCore.Rest, 6.0.0"
#:package Winche.Storage.AspNetCore.Rest@6.0.0
#addin nuget:?package=Winche.Storage.AspNetCore.Rest&version=6.0.0
#tool nuget:?package=Winche.Storage.AspNetCore.Rest&version=6.0.0
Winche.Storage
Lightweight .NET libraries for storing file metadata in PostgreSQL and objects in S3-compatible archives via presigned URLs. The solution includes core abstractions, an S3 archive provider, an ASP.NET Core base + REST adapter, and a sample app.
Access control is enforced with Winche.Rules — the same Firestore-style rules engine used by Winche.Database.
Packages
| Package | Description |
|---|---|
Winche.Storage |
Core: schema management, file CRUD, hooks, Winche.Rules guard |
Winche.Storage.S3 |
S3-compatible archive provider (presigned URLs, multipart upload) |
Winche.Storage.AspNetCore |
ASP.NET Core base: HTTP-context claims mapping (MapClaims) |
Winche.Storage.AspNetCore.Rest |
Minimal-API REST endpoints (depends on the base package) |
Architecture
Package dependencies:
flowchart TD
Rest["Winche.Storage.AspNetCore.Rest<br/>MapWincheStorageRestApi"]
Base["Winche.Storage.AspNetCore<br/>FileClaimsAccessor · MapClaims"]
S3["Winche.Storage.S3<br/>UseS3Archive · S3Archive"]
Core["Winche.Storage<br/>IFileStorage · guard · schema · hooks"]
Rules["Winche.Rules<br/>RuleEngine · RulesetBuilder · Expr"]
Rest --> Base
Rest --> Core
Base --> Core
S3 --> Core
Core --> Rules
Runtime components inside the core. The public IFileStorage is the rules guard; it authorizes
through the RuleEngine, then delegates to the unprotected FileStorage, which talks to Postgres
and the pluggable archive:
flowchart LR
IFM["IFileStorage"] --> Guard["RuleGuardedFileStorage"]
Guard -->|authorize| Engine["RuleEngine"]
Guard -->|delegate| Core["FileStorage"]
Engine --> Claims["IRuleClaimsAccessor"]
Core --> DB[("PostgreSQL")]
Core --> Archive["IArchive<br/>NullArchive / S3Archive"]
Install
dotnet add package Winche.Storage
dotnet add package Winche.Storage.S3
dotnet add package Winche.Storage.AspNetCore.Rest
Quick Start
1. Configure appsettings.json
{
"ConnectionStrings": {
"WincheStorage": "Host=localhost;Database=mydb;Username=user;Password=pass"
},
"WincheStorage:S3Archive": {
"BucketName": "your-bucket",
"RegionName": "us-east-1",
"AccessKey": "YOUR_ACCESS_KEY",
"SecretKey": "YOUR_SECRET_KEY",
"PresignedUrlExpiry": "00:15:00"
}
}
AccessKey and SecretKey are optional. Omit them when deploying to AWS with a task role or instance profile — the SDK uses ambient IAM credentials automatically.
For a non-public schema, add Search Path=myschema to the connection string (the metadata table is created in the connection's search_path).
2. Register services
using Winche.Rules;
using Winche.Rules.Expressions;
using Winche.Storage.AspNetCore.DependencyInjection; // MapClaims
using Winche.Storage.DependencyInjection; // AddWincheStorage
using Winche.Storage.S3.DependencyInjection; // UseS3Archive
builder.Services.AddWincheStorage(opts =>
{
opts.ConnectionString = builder.Configuration.GetConnectionString("WincheStorage");
// Default-deny. Grant access with one or more rule blocks.
opts.UseRules(r => r.Match("userFiles/{userId}/{rest=**}", owned =>
owned.Allow(RuleOperations.All, Expr.Auth("token", "userId").Eq(Expr.Param("userId")))));
opts.UseS3Archive(s3 => builder.Configuration.GetSection("WincheStorage:S3Archive").Bind(s3));
// Map HTTP requests to caller claims consumed by the rules engine.
opts.MapClaims(ctx => new Dictionary<string, object?>
{
["userId"] = ctx.Request.Headers["X-USER-ID"].ToString(),
});
});
3. Initialize schema and map endpoints
await app.InitializeWincheStorageAsync(); // creates the winche_files table if it doesn't exist
app.MapWincheStorageRestApi(); // maps REST routes under the "files/" prefix
See samples/Winche.Storage.Sample for a complete working example.
Configuration
AddWincheStorage
A single overload takes an Action<WincheStorageOptions>. Set the connection string and register
components inside the lambda:
services.AddWincheStorage(opts =>
{
opts.ConnectionString = "Host=...;Database=...;Username=...;Password=...";
opts.UseRules(/* ... */);
opts.UseHooks(h => h.Add<AuditHook>("userFiles/{userId}/{file=**}"));
opts.UseS3Archive(/* Action<S3ArchiveOptions> */);
opts.MapClaims(/* Func<HttpContext, IReadOnlyDictionary<string, object?>?> */);
});
WincheStorageOptions
| Member | Description |
|---|---|
ConnectionString |
(required) Postgres connection string. Schema comes from its Search Path. |
UseRules(...) |
Adds a RuleSet to the guard. Multiple calls accumulate (OR-combined). |
UseHooks(h => h.Add<T>(path)) |
Registers FileStoreHook lifecycle listeners, each bound to a Firestore-style path pattern. |
UseS3Archive (from Winche.Storage.S3) and MapClaims (from Winche.Storage.AspNetCore) are
extension methods on WincheStorageOptions.
S3ArchiveOptions
| Property | Default | Description |
|---|---|---|
BucketName |
(required) | S3 bucket name |
RegionName |
(required) | AWS region (e.g. "us-east-1") |
AccessKey |
null |
Optional — omit to use ambient credentials |
SecretKey |
null |
Optional — omit to use ambient credentials |
PresignedUrlExpiry |
00:15:00 |
Lifetime of generated presigned URLs |
Configure S3 with a delegate (bind from IConfiguration yourself if you prefer):
opts.UseS3Archive(s3 =>
{
s3.BucketName = "my-bucket";
s3.RegionName = "eu-west-1";
});
REST Endpoints
All {path} segments are base64url-encoded file paths (or directory paths for :list). CRUD
uses HTTP methods; per-file operations use AIP-136 colon-verbs (all POST).
| Method | Route | Description |
|---|---|---|
PUT |
/{path} |
Register a new file record |
GET |
/{path} |
Get a file record |
PATCH |
/{path} |
Update file metadata |
DELETE |
/{path} |
Delete a file record (and its archive object) |
GET |
/ping |
Liveness check |
POST |
/{path}:confirm |
Confirm an upload is complete |
POST |
/{path}:upload |
Generate a presigned upload URL |
POST |
/{path}:download |
Generate a presigned download URL |
POST |
/{path}:list |
List files in a directory (?mimeType= filter optional) |
POST |
/{path}:signPart |
Sign a multipart upload part ({ "partNumber": N }) |
POST |
/{path}:listParts |
List uploaded parts for a multipart upload |
MapWincheStorageRestApi returns a single IEndpointConventionBuilder covering every endpoint, so
cross-cutting policy applies to all of them:
app.MapWincheStorageRestApi(prefix: "storage")
.RequireAuthorization();
Request pipeline
Every endpoint runs the built-in ClaimsAccessor filter (maps the request to caller claims) and the
ExceptionHandler filter (translates exceptions to status codes), outermost, before the handler
decodes the base64url path and calls IFileStorage:
flowchart LR
Req["HTTP<br/>/files/{base64url}:verb"] --> CA["ClaimsAccessor<br/>SetClaims(HttpContext)"]
CA --> EH["ExceptionHandler"]
EH --> Handler["handler<br/>DecodePath → IFileStorage"]
Handler --> Ok["Results.Json"]
EH -. on exception .-> Err["403 AccessDenied<br/>404 NotFound<br/>400 InvalidStatus<br/>500 other"]
Upload lifecycle
A record is registered first (status pending), uploaded directly to the archive via presigned URLs
(single or multipart), then confirmed (status complete). Downloads require complete:
sequenceDiagram
actor Client
participant API as Storage API
participant Arch as Archive
Client->>API: PUT /{path} (register, status=pending)
alt single upload
Client->>API: POST /{path}:upload
API-->>Client: presigned PUT URL
Client->>Arch: upload object
else multipart upload
loop part 1..N
Client->>API: POST /{path}:signPart {partNumber}
API-->>Client: presigned part URL
Client->>Arch: upload part
end
end
Client->>API: POST /{path}:confirm
API->>Arch: complete / verify object
API-->>Client: FileRecord (status=complete)
Client->>API: POST /{path}:download
API-->>Client: presigned GET URL
stateDiagram-v2
[*] --> pending: register
pending --> complete: confirm
complete --> [*]: download
Access Control
Authorization is expressed as Winche.Rules rule
blocks via opts.UseRules(...). Access is default-deny: with no matching Allow, every
protected call throws AccessDeniedException. Multiple UseRules calls accumulate and are
OR-combined.
opts.UseRules(r => r
.Match("userFiles/{userId}/{rest=**}", owned =>
{
// Owner has full access to their own subtree.
owned.Allow(RuleOperations.All, Expr.Auth("token", "userId").Eq(Expr.Param("userId")));
})
.Match("public/{rest=**}", pub =>
{
// Anyone may read public files.
pub.Allow(RuleOperations.Read, Expr.Const(true));
}));
- Path patterns use
{param}single-segment captures and a trailing{name=**}recursive capture, readable in conditions viaExpr.Param("param"). - Operations:
RuleOperations.Read(get + list),RuleOperations.Write(create + update + delete),RuleOperations.All, or individualRuleOperationvalues. - The existing file is exposed to conditions as
resource— e.g.Expr.Resource("mimeType"),Expr.Resource("sizeBytes"),Expr.Resource("metadata", "ownerId"). - Caller claims are exposed under
request.auth:Expr.Auth("uid")(theuidclaim, if any) andExpr.Auth("token", "<claim>")(any mapped claim).
Storage operations map to rule operations as follows:
IFileStorage call |
Rule operation |
|---|---|
SetAsync |
Create |
GetAsync, GenerateDownloadUrlAsync, ListUploadedPartsAsync |
Get |
ListAsync |
List |
UpdateMetadataAsync, ConfirmUploadAsync, GenerateUploadUrlAsync, SignPartAsync |
Update |
DeleteAsync |
Delete |
Authorization flow
A protected call loads the current record (as resource), gathers caller claims, and asks the
RuleEngine. If no rule allows the operation it throws AccessDeniedException (default-deny);
otherwise it delegates to the unprotected core. Writes follow the same path, authorizing before the
mutation (a create has no prior resource):
sequenceDiagram
actor Caller
participant Guard as RuleGuardedFileStorage
participant Core as FileStorage
participant Claims as IRuleClaimsAccessor
participant Engine as RuleEngine
Caller->>Guard: GetAsync(path)
Guard->>Core: GetAsync(path)
Core-->>Guard: FileRecord (resource)
Guard->>Claims: GetClaims()
Claims-->>Guard: caller claims
Guard->>Engine: AllowsAsync(Get, path, request)
alt allowed
Engine-->>Guard: true
Guard-->>Caller: FileRecord
else denied
Engine-->>Guard: false
Guard--xCaller: AccessDeniedException
end
Claims mapping
MapClaims (from Winche.Storage.AspNetCore) maps the HTTP request to the caller-claims dictionary
the rules engine reads. The dictionary is exposed as request.auth.token.*, and a uid key (if
present) also as request.auth.uid.
opts.MapClaims(ctx => new Dictionary<string, object?>
{
["uid"] = ctx.User.FindFirst("sub")?.Value,
["userId"] = ctx.Request.Headers["X-USER-ID"].ToString(),
});
For non-HTTP callers (background services, tests), inject FileClaimsAccessor and call
SetClaims(...) directly before invoking IFileStorage.
Hooks
Implement FileStoreHook (behavior only) and register it against a path with
UseHooks(h => h.Add<T>(path)). The path is a Firestore-style pattern (literal segments, {id}
single-segment captures, and a trailing {name=**} recursive wildcard matching one or more
segments; bare */** are not valid). The same hook type can be bound to multiple paths. Hooks are
dispatched asynchronously.
public class AuditHook : FileStoreHook
{
public override Task OnFileRegisteredAsync(FileRecord record, CancellationToken ct) { ... }
public override Task OnUploadConfirmedAsync(FileRecord record, CancellationToken ct) { ... }
public override Task OnFileDeletedAsync(string path, CancellationToken ct) { ... }
public override Task OnMetadataUpdatedAsync(FileRecord record, CancellationToken ct) { ... }
public override Task OnUploadUrlGeneratedAsync(string path, UploadSession session, CancellationToken ct) { ... }
public override Task OnDownloadUrlGeneratedAsync(string path, DownloadSession session, CancellationToken ct) { ... }
}
Register: opts.UseHooks(h => h.Add<AuditHook>("userFiles/{userId}/{file=**}")).
IFileStorage
Inject IFileStorage to interact with the store — it always resolves to the rules guard, which
authorizes every call via Winche.Rules. Trusted server-side callers that have no request claims
(background services, hooks, schedulers) should inject the concrete FileStorage instead, which is
the unguarded core. This mirrors Winche.Database's IDocumentDatabase (guarded) vs DocumentDatabase
(unguarded) split.
// IFileStorage — every call is authorized via Winche.Rules
Task<FileRecord> SetAsync(string path, string mimeType, long sizeBytes, JsonObject? metadata, CancellationToken ct);
Task<FileRecord?> GetAsync(string path, CancellationToken ct);
Task<FileRecord?> UpdateMetadataAsync(string path, JsonObject patch, CancellationToken ct);
Task<bool> DeleteAsync(string path, CancellationToken ct);
Task<UploadSession> GenerateUploadUrlAsync(string path, CancellationToken ct);
Task<DownloadSession> GenerateDownloadUrlAsync(string path, CancellationToken ct);
Task<FileRecord> ConfirmUploadAsync(string path, CancellationToken ct);
Task<IEnumerable<FileRecord>> ListAsync(string directory, string? mimeType, CancellationToken ct);
Task<UploadSession> SignPartAsync(string path, int partNumber, CancellationToken ct);
Task<IEnumerable<FilePart>> ListUploadedPartsAsync(string path, CancellationToken ct);
For trusted, claim-less callers, inject the concrete core — same surface, no authorization:
public sealed class ReportMailer(FileStorage files) // concrete FileStorage = unguarded
{
public Task<DownloadSession> LinkAsync(string path, CancellationToken ct) =>
files.GenerateDownloadUrlAsync(path, ct);
}
FileRecord
| Field | Type | Description |
|---|---|---|
id |
string |
Unique record identifier |
path |
string |
Full logical path |
directory |
string |
Parent directory segment |
mimeType |
string |
MIME type |
sizeBytes |
long |
Declared file size |
uploadStatus |
UploadStatus |
pending, complete, or failed |
uploadId |
string? |
Multipart upload ID (when active) |
metadata |
JsonObject |
Arbitrary key/value metadata |
version |
long |
Optimistic-concurrency version counter |
createdAt |
DateTime |
Creation timestamp |
updatedAt |
DateTime |
Last-modified timestamp |
Requirements
- .NET 10 SDK (
net10.0) - PostgreSQL for metadata storage
- An S3-compatible bucket for object storage (AWS S3, MinIO, etc.)
License
Elastic License 2.0
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Winche.Storage (>= 6.0.0)
- Winche.Storage.AspNetCore (>= 6.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.