![]() |
VOOZH | about |
dotnet add package Dosaic.Plugins.Persistence.S3 --version 1.2.34
NuGet\Install-Package Dosaic.Plugins.Persistence.S3 -Version 1.2.34
<PackageReference Include="Dosaic.Plugins.Persistence.S3" Version="1.2.34" />
<PackageVersion Include="Dosaic.Plugins.Persistence.S3" Version="1.2.34" />Directory.Packages.props
<PackageReference Include="Dosaic.Plugins.Persistence.S3" />Project file
paket add Dosaic.Plugins.Persistence.S3 --version 1.2.34
#r "nuget: Dosaic.Plugins.Persistence.S3, 1.2.34"
#:package Dosaic.Plugins.Persistence.S3@1.2.34
#addin nuget:?package=Dosaic.Plugins.Persistence.S3&version=1.2.34Install as a Cake Addin
#tool nuget:?package=Dosaic.Plugins.Persistence.S3&version=1.2.34Install as a Cake Tool
Dosaic.Plugins.Persistence.S3 is a plugin that provides S3-compatible object storage for Dosaic applications. It wraps the Minio client, adds automatic MIME-type detection via Mime-Detective, bucket-prefixing, SHA-256 hashing, OpenTelemetry tracing, and a local-filesystem fallback for development and testing.
dotnet add package Dosaic.Plugins.Persistence.S3
or add as a package reference to your .csproj:
<PackageReference Include="Dosaic.Plugins.Persistence.S3" Version=""/>
appsettings.ymls3:
endpoint: "s3.example.com" # S3 / MinIO endpoint (host[:port])
accessKey: "your-access-key"
secretKey: "your-secret-key"
region: "us-east-1" # optional
useSsl: true # optional, default false
bucketPrefix: "myapp-" # optional, prefixed to every bucket name
healthCheckPath: "" # optional, path appended to endpoint URL for readiness check
useLocalFileSystem: false # optional, use local filesystem instead of S3 (dev/test mode)
localFileSystemPath: "./nodep-s3" # optional, root path used when useLocalFileSystem is true
When useLocalFileSystem: true the plugin stores files on the local disk at localFileSystemPath instead of connecting to an S3 endpoint. This is useful for local development and integration tests where no MinIO/S3 instance is available.
The plugin is automatically discovered and registered by the Dosaic source generator when using PluginWebHostBuilder. No manual registration is required in that case.
Define an enum whose values are annotated with [FileBucket]. The attribute declares the bucket name and the allowed FileType for validation:
public enum MyBucket
{
[FileBucket("logos", FileType.Images)]
Logos = 0,
[FileBucket("avatars", FileType.Images)]
Avatars = 1,
[FileBucket("docs", FileType.Documents)]
Documents = 2,
}
Then register IFileStorage<MyBucket> in DI:
// Storage only
services.AddFileStorage<MyBucket>();
// Storage + automatic bucket-creation on startup (recommended for production)
services.AddFileStorageWithBucketMigration<MyBucket>();
// Or register them separately
services.AddFileStorage<MyBucket>();
services.AddBlobStorageBucketMigrationService<MyBucket>();
IFileStorage<MyBucket> can then be injected anywhere in your application.
The plugin also registers an untyped IFileStorage. Because there is no enum to inspect, no bucket migration service exists for this interface — you must create buckets manually at runtime:
public class FileProvider(IFileStorage fileStorage)
{
public async Task EnsureBucketAsync(CancellationToken cancellationToken)
{
await fileStorage.CreateBucketAsync("my-bucket", cancellationToken);
}
}
services.AddS3BlobStoragePlugin(new S3Configuration
{
Endpoint = "s3.example.com",
AccessKey = "your-access-key",
SecretKey = "your-secret-key",
BucketPrefix = "myapp-", // optional
Region = "us-east-1", // optional
UseSsl = true, // optional
HealthCheckPath = "", // optional
});
BlobFileBlobFile<TBucket> carries the file metadata and the target bucket/key. Use the fluent helpers to attach filename or extension metadata:
// Auto-generated key (UUID), sets original-filename and file-extension metadata
var file = new BlobFile<MyBucket>(MyBucket.Logos).WithFilename("company-logo.png");
// Explicit key, sets only file-extension metadata
var file = new BlobFile<MyBucket>(MyBucket.Logos, "my-custom-key")
.WithFileExtension(".pdf");
file.MetaData[BlobFileMetaData.ContentType] = "application/pdf"; // override content-type
// Generate a new random FileId directly
var fileId = FileId<MyBucket>.New(MyBucket.Logos);
public class FileService(IFileStorage<MyBucket> fileStorage)
{
public async Task<string> UploadLogoAsync(Stream stream, string originalName,
CancellationToken cancellationToken = default)
{
var file = new BlobFile<MyBucket>(MyBucket.Logos).WithFilename(originalName);
var fileId = await fileStorage.SetAsync(file, stream, cancellationToken);
// fileId.Id is the Sqids-encoded public identifier (bucket + key)
return fileId.Id;
}
}
public async Task<BlobFile<MyBucket>> GetMetadataAsync(string encodedId,
CancellationToken cancellationToken = default)
{
if (!FileId<MyBucket>.TryParse(encodedId, out var fileId))
throw new ArgumentException("Invalid file id.");
return await fileStorage.GetFileAsync(fileId, cancellationToken);
}
public async Task DownloadAsync(string encodedId, Stream destination,
CancellationToken cancellationToken = default)
{
if (!FileId<MyBucket>.TryParse(encodedId, out var fileId))
throw new ArgumentException("Invalid file id.");
await fileStorage.ConsumeStreamAsync(fileId,
async (stream, ct) => await stream.CopyToAsync(destination, ct),
cancellationToken);
}
public async Task DeleteAsync(string encodedId, CancellationToken cancellationToken = default)
{
if (!FileId<MyBucket>.TryParse(encodedId, out var fileId))
throw new ArgumentException("Invalid file id.");
await fileStorage.DeleteFileAsync(fileId, cancellationToken);
}
Both IFileStorage and IFileStorage<TBucket> implement IComputeHash. The hash is also stored automatically in BlobFileMetaData.Hash when a file is uploaded.
string hash = await fileStorage.ComputeHash(stream, cancellationToken);
IFileTypeDefinitionResolverImplement IFileTypeDefinitionResolver and register it to replace the default definitions:
internal class PdfOnlyFileTypeDefinitionResolver : IFileTypeDefinitionResolver
{
public ImmutableArray<Definition> GetDefinitions(FileType fileType)
{
return DefaultDefinitions.FileTypes.Documents.All()
.Where(x => x.File.Extensions.Contains("pdf"))
.ToImmutableArray();
}
}
// Registration
services.ReplaceDefaultFileTypeDefinitionResolver<PdfOnlyFileTypeDefinitionResolver>();
The built-in implementation is DefaultFileTypeDefinitionResolver, which delegates to MimeDetective.Definitions.DefaultDefinitions.
IContentInspectorThe content inspector is used for binary MIME detection when no file extension is available:
// Replace with a custom definition list
services.ReplaceContentInspector(
DefaultDefinitions.All()
.Where(x => x.File.Extensions.Contains("pdf"))
.ToList());
// Or replace the full singleton
services.Replace(ServiceDescriptor.Singleton<IContentInspector>(_ =>
new ContentInspectorBuilder
{
Definitions = DefaultDefinitions.All()
.Where(x => x.File.Extensions.Contains("pdf"))
.ToList()
}.Build()));
When BlobFileMetaData.ContentType is not set on a BlobFile, the plugin detects it automatically in this order:
BlobFileMetaData.FileExtension is set → look up via IFileTypeDefinitionResolver.IContentInspector.application/octet-stream.After detection, the content-type is validated against the FileType declared on the [FileBucket] attribute. If they do not match, a ValidationDosaicException is thrown. Use FileType.Any to skip validation entirely.
BlobFileMetaData)| Constant | Key | Description |
|---|---|---|
BlobFileMetaData.Filename |
original-filename |
Original file name |
BlobFileMetaData.FileExtension |
original-file-extension |
File extension (e.g. .pdf) |
BlobFileMetaData.ContentType |
content-type |
MIME type |
BlobFileMetaData.ContentLength |
content-length |
File size in bytes |
BlobFileMetaData.ETag |
etag |
S3 ETag (quoted) |
BlobFileMetaData.Hash |
hash |
SHA-256 hex digest (auto-computed on upload) |
FileId and FileId<TBucket> encode the bucket name and object key as a single Sqids-encoded string accessible via the .Id property. This opaque identifier is safe to expose in URLs and query strings.
// Parse an incoming opaque id
if (!FileId<MyBucket>.TryParse(incomingId, out var fileId))
return Results.NotFound();
// Generate a new random id
var newFileId = FileId<MyBucket>.New(MyBucket.Logos);
Console.WriteLine(newFileId.Id); // e.g. "aBcDeFgH"
Console.WriteLine(newFileId.Key); // the raw UUID key
Console.WriteLine(newFileId.Bucket); // MyBucket.Logos
Example of wrapping the storage interface with permission checks:
public class FileProvider(IFileStorage<MyBucket> fileStorage)
{
private Task CheckPermissionAsync(FileId<MyBucket> fileId, CancellationToken cancellationToken)
{
// check permissions or ACL
return Task.CompletedTask;
}
public async Task<BlobFile<MyBucket>> GetFileAsync(FileId<MyBucket> id, CancellationToken cancellationToken = default)
{
await CheckPermissionAsync(id, cancellationToken);
return await fileStorage.GetFileAsync(id, cancellationToken);
}
public async Task ConsumeStreamAsync(FileId<MyBucket> id, Func<Stream, CancellationToken, Task> streamConsumer, CancellationToken cancellationToken = default)
{
await CheckPermissionAsync(id, cancellationToken);
await fileStorage.ConsumeStreamAsync(id, streamConsumer, cancellationToken);
}
public async Task<FileId<MyBucket>> SetAsync(BlobFile<MyBucket> file, Stream stream, CancellationToken cancellationToken = default)
{
await CheckPermissionAsync(file.Id, cancellationToken);
return await fileStorage.SetAsync(file, stream, cancellationToken);
}
public async Task DeleteFileAsync(FileId<MyBucket> id, CancellationToken cancellationToken = default)
{
await CheckPermissionAsync(id, cancellationToken);
await fileStorage.DeleteFileAsync(id, cancellationToken);
}
}
[ApiController, Route("/files"), Authorize]
public class FilesController(IFileStorage<MyBucket> fileStorage) : ControllerBase
{
[HttpGet("{key:required}")]
public async Task<IResult> GetFileByKeyAsync([FromRoute] string key, CancellationToken cancellationToken)
{
if (!FileId<MyBucket>.TryParse(key, out var fileId))
return Results.StatusCode(StatusCodes.Status404NotFound);
var file = await fileStorage.GetFileAsync(fileId, cancellationToken);
var etag = file.MetaData[BlobFileMetaData.ETag];
var lastModified = file.LastModified;
if (CheckIfResponseIsNotModified(etag, lastModified))
return Results.StatusCode(StatusCodes.Status304NotModified);
var fileName = file.MetaData.TryGetValue(BlobFileMetaData.Filename, out var value) ? value : fileId.Id;
Response.Headers.Append("Content-Length", file.MetaData[BlobFileMetaData.ContentLength]);
Response.Headers.Append("Cache-Control", "private, max-age=300, immutable, must-revalidate");
return Results.Stream(sr => fileStorage.ConsumeStreamAsync(fileId, async (stream, ct) => await stream.CopyToAsync(sr, ct), cancellationToken), file.MetaData[BlobFileMetaData.ContentType], fileName, lastModified, new EntityTagHeaderValue(etag));
}
private bool CheckIfResponseIsNotModified(string etag, DateTimeOffset lastModified)
{
if (Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch) && ifNoneMatch == etag)
return true;
return Request.Headers.TryGetValue("If-Modified-Since", out var ifModifiedSince) &&
DateTime.TryParse(ifModifiedSince, out var modifiedSince) &&
modifiedSince >= lastModified;
}
}
useLocalFileSystem: true) for zero-dependency dev/test environmentsIFileStorage<TBucket> and per-bucket FileType validationIFileStorage and runtime CreateBucketAsyncBlobStorageBucketMigrationService<T> (hosted background service with retry)Dosaic.Hosting.Abstractions.Tracing entry point (Dosaic ActivitySource); each operation uses Tracing.TrackStatusAsync so spans get Ok/Error status automaticallyIFileTypeDefinitionResolver and IContentInspector for custom MIME handling| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 net10.0 is compatible. net10.0-android net10.0-android was computed. net10.0-browser net10.0-browser was computed. net10.0-ios net10.0-ios was computed. net10.0-maccatalyst net10.0-maccatalyst was computed. net10.0-macos net10.0-macos was computed. net10.0-tvos net10.0-tvos was computed. net10.0-windows net10.0-windows was computed. |
This package is not used by any NuGet packages.
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.2.34 | 100 | 6/10/2026 |
| 1.2.33 | 102 | 6/2/2026 |
| 1.2.31 | 103 | 5/28/2026 |
| 1.2.30 | 108 | 5/7/2026 |
| 1.2.29 | 110 | 5/5/2026 |
| 1.2.28 | 119 | 4/30/2026 |
| 1.2.27 | 100 | 4/29/2026 |
| 1.2.26 | 107 | 4/29/2026 |
| 1.2.25 | 124 | 4/27/2026 |
| 1.2.24 | 110 | 4/21/2026 |
| 1.2.23 | 124 | 4/14/2026 |
| 1.2.22 | 108 | 4/10/2026 |
| 1.2.21 | 104 | 4/10/2026 |
| 1.2.20 | 110 | 4/10/2026 |
| 1.2.19 | 112 | 4/9/2026 |
| 1.2.18 | 121 | 4/2/2026 |
| 1.2.17 | 110 | 4/1/2026 |
| 1.2.16 | 112 | 4/1/2026 |
| 1.2.15 | 111 | 3/31/2026 |
| 1.2.14 | 115 | 3/30/2026 |