NinjaVault.Cdn
100.42.1
dotnet add package NinjaVault.Cdn --version 100.42.1
NuGet\Install-Package NinjaVault.Cdn -Version 100.42.1
<PackageReference Include="NinjaVault.Cdn" Version="100.42.1" />
<PackageVersion Include="NinjaVault.Cdn" Version="100.42.1" />
<PackageReference Include="NinjaVault.Cdn" />
paket add NinjaVault.Cdn --version 100.42.1
#r "nuget: NinjaVault.Cdn, 100.42.1"
#:package NinjaVault.Cdn@100.42.1
#addin nuget:?package=NinjaVault.Cdn&version=100.42.1
#tool nuget:?package=NinjaVault.Cdn&version=100.42.1
<p align="center"> <img src="https://raw.githubusercontent.com/alisaivi786/NinjaVault/main/assets/icon-512.png" width="112" alt="NinjaVault" /> </p>
<h1 align="center">NinjaVault.Cdn</h1>
<p align="center"> Typed .NET client for the <b>NinjaVault CDN Server</b>: upload, search, download and share files with one injected interface. </p>
<p align="center"> <a href="https://www.nuget.org/packages/NinjaVault.Cdn"><img src="https://img.shields.io/nuget/v/NinjaVault.Cdn.svg?label=NinjaVault.Cdn" alt="NuGet version" /></a> <a href="https://www.nuget.org/packages/NinjaVault.Cdn"><img src="https://img.shields.io/nuget/dt/NinjaVault.Cdn.svg" alt="NuGet downloads" /></a> <a href="https://github.com/alisaivi786/NinjaVault/actions/workflows/ci.yml"><img src="https://github.com/alisaivi786/NinjaVault/actions/workflows/ci.yml/badge.svg" alt="CI" /></a> <img src="https://img.shields.io/badge/.NET-8%20%7C%209%20%7C%2010-512BD4" alt=".NET 8, 9, 10" /> <a href="https://github.com/alisaivi786/NinjaVault/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT" /></a> </p>
Why NinjaVault.Cdn
- One interface, every operation. Upload, list/search, metadata, usage summary, download, soft delete, public URLs, and single or batch presigned URLs.
- No HTTP plumbing. Handles the
X-Api-Keyheader, multipart fields, object-key encoding, and the JSON success/error envelope for you. - Typed errors. Every failure is a
CdnApiExceptionwith the HTTP status, CDN error code, trace id, validation details, andRetry-After. - Standalone, no opinions. Depends only on
Microsoft.Extensions.*. Logging, retries and correlation stay under your app's control: plug in whatever you already use. - Plays well with your stack. Built on
IHttpClientFactory, so you can chain logging handlers, Polly orMicrosoft.Extensions.Http.Resilience, and the interface is easy to mock in tests. - Streams large files. Downloads are handed to you as a stream and never buffered in memory.
Quick start
1. Install
dotnet add package NinjaVault.Cdn
2. Configure (appsettings.json)
{
"NinjaVault": {
"Cdn": {
"BaseUrl": "https://cdn.example.com",
"ApiKey": ""
}
}
}
Keep the API key out of the file:
dotnet user-secrets set "NinjaVault:Cdn:ApiKey" "cdn_xxxxx" # local development
export NinjaVault__Cdn__ApiKey="cdn_xxxxx" # servers / containers
3. Register and use
builder.Services.AddNinjaVaultCdn(builder.Configuration);
public sealed class InvoiceService(INinjaVaultCdnClient cdn)
{
public async Task<string> SaveAsync(Stream pdf, long tenantId, CancellationToken ct)
{
CdnUploadResult file = await cdn.UploadAsync(
new CdnUploadRequest("documents", tenantId, pdf, "invoice.pdf", "application/pdf"), ct);
return file.ObjectKey; // store Bucket + ObjectKey in your database
}
}
That's it. Everything below is optional.
Configuration reference
{
"NinjaVault": {
"Cdn": {
"BaseUrl": "https://cdn.example.com",
"PublicBaseUrl": "https://cdn.example.com",
"ApiKey": ""
}
}
}
| Key | Required | Default | Description |
|---|---|---|---|
NinjaVault:Cdn:BaseUrl |
✅ | none | Base URL of the authenticated /api/v1/... routes. A path prefix works (https://gateway.example.com/cdn). |
NinjaVault:Cdn:ApiKey |
✅ | none | The X-Api-Key issued for your integration. Backend-only secret. |
NinjaVault:Cdn:PublicBaseUrl |
BaseUrl |
Host for anonymous public files. Host only; the client appends /public/. |
Environment variables use __ in place of : (for example NinjaVault__Cdn__BaseUrl).
Configure in code instead
builder.Services.AddNinjaVaultCdn(options =>
{
options.BaseUrl = "https://cdn.example.com";
options.ApiKey = builder.Configuration["Secrets:CdnApiKey"]!;
});
Add resilience (retries, timeouts, circuit breaker)
Both AddNinjaVaultCdn overloads return IHttpClientBuilder:
// dotnet add package Microsoft.Extensions.Http.Resilience
builder.Services.AddNinjaVaultCdn(builder.Configuration)
.AddStandardResilienceHandler();
Verify the key at startup (recommended)
using (IServiceScope scope = app.Services.CreateScope())
{
INinjaVaultCdnClient cdn = scope.ServiceProvider.GetRequiredService<INinjaVaultCdnClient>();
CdnAccessContext access = await cdn.GetAccessContextAsync();
// access.AllowedBuckets / AllowedTenantIds empty = unrestricted, not "no access"
}
Usage
All examples assume INinjaVaultCdnClient cdn is injected and ct is a CancellationToken.
Upload
CdnUploadResult uploaded = await cdn.UploadAsync(new CdnUploadRequest(
Bucket: "documents",
TenantId: 42,
File: stream,
FileName: "report.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
OwnerId: 1001, // optional
FolderPath: "reports/2026"), // optional: up to 8 segments of [A-Za-z0-9_-]
ct);
// uploaded.Bucket + uploaded.ObjectKey identify the file from now on (not the original file name).
Allowed content types and the maximum size are set per CDN deployment (commonly PDF, JPEG, PNG, WEBP, DOC and DOCX, up to about 25 MB). Anything outside them fails with 40001.
Give a browser or mobile app access
| The file is in... | Use | Makes an HTTP call? | Link lifetime |
|---|---|---|---|
| a Public bucket | cdn.BuildPublicUrl(bucket, objectKey) |
no | permanent |
| a Private bucket | cdn.CreatePresignedUrlAsync(...) |
yes | short (you choose) |
| many private files | cdn.CreatePresignedUrlsAsync(...) |
yes, once | short |
string logoUrl = cdn.BuildPublicUrl("public-assets", objectKey);
CdnPresignedUrl link = await cdn.CreatePresignedUrlAsync(
new CdnPresignRequest("documents", objectKey, ExpirySeconds: 600), ct);
CdnPresignBatchResult batch = await cdn.CreatePresignedUrlsAsync(new CdnPresignBatchRequest(
[new CdnPresignTarget("documents", keyA), new CdnPresignTarget("documents", keyB)],
ExpirySeconds: 600), ct);
foreach (CdnPresignFailure failed in batch.Failed) // batch returns 200 even if some fail
{
logger.LogWarning("{Key}: {Reason}", failed.ObjectKey, failed.Reason);
}
Never send the API key to a browser or mobile app. Give them a public or presigned URL instead.
Download (backend to backend)
await using CdnFileDownload download = await cdn.DownloadAsync("documents", objectKey, ct);
// Stream straight through without loading the file into memory
return Results.Stream(download.Content, download.ContentType, download.FileName);
Search and list
CdnPagedResult<CdnFileObject> page = await cdn.ListFilesAsync(new CdnFileListQuery
{
Bucket = "documents",
TenantId = 42,
OwnerId = 1001,
ObjectKeyPrefix = "reports/2026",
FileNameContains = "invoice",
Category = CdnFileCategory.Document,
CreatedFromUtc = DateTimeOffset.UtcNow.AddDays(-30),
SortBy = CdnFileSortBy.CreatedAtUtc,
SortDescending = true,
Page = 1,
PageSize = 50
}, ct);
Metadata, usage, buckets, delete
CdnFileObject info = await cdn.GetMetadataAsync("documents", objectKey, ct); // checksum, category, thumbnail...
CdnFileSummary usage = await cdn.GetSummaryAsync("documents", ct); // totals per category
IReadOnlyList<CdnBucket> buckets = await cdn.ListBucketsAsync(ct); // visibility + counters
await cdn.DeleteAsync("documents", objectKey, ct); // soft delete
Error handling
try
{
await cdn.UploadAsync(request, ct);
}
catch (CdnApiException ex) when (ex.ErrorCode == 42901 && ex.RetryAfter is { } wait)
{
await Task.Delay(wait, ct); // rate limited: back off exactly as long as the server asks
}
catch (CdnApiException ex)
{
logger.LogWarning("CDN {Status} {Code}: {Message} (trace {TraceId})",
(int?)ex.StatusCode, ex.ErrorCode, ex.Message, ex.CorrelationId);
foreach ((string field, string[] errors) in ex.Details ?? new Dictionary<string, string[]>())
{
logger.LogWarning("{Field}: {Errors}", field, string.Join(", ", errors));
}
}
ErrorCode |
HTTP | Meaning | What to check |
|---|---|---|---|
40001 |
400 | Validation failed | FolderPath format, content type, file size, ex.Details |
40101 |
401 | Unauthorized | ApiKey missing, mistyped, or revoked |
40301 |
403 | Forbidden | Key not allowed for this bucket or tenant (GetAccessContextAsync) |
40401 |
404 | Not found | Bucket name or object key (use ObjectKey, not the file name) |
40901 |
409 | Conflict | Quota exceeded or conflicting state |
42901 |
429 | Too many requests | Wait for ex.RetryAfter |
50001 |
500 | Server error | Retry later; send ex.CorrelationId to the CDN team |
Logging, correlation and retries
NinjaVault.Cdn adds no logging of its own. AddNinjaVaultCdn(...) returns the IHttpClientBuilder for the
named client NinjaVaultCdn (DependencyInjection.ServiceName), so you attach exactly what your app uses.
| You want... | Add |
|---|---|
| Nothing extra | Nothing. IHttpClientFactory already writes request start/end entries to ILogger under the category System.Net.Http.HttpClient.NinjaVaultCdn.*, so they reach Serilog, Seq or the console. |
Ready-made request/response logging, a DB sink and X-Correlation-Id |
NinjaVault.Http (optional package): .AddNinjaVaultHttpLogging(DependencyInjection.ServiceName) |
| Microsoft's structured HTTP logging | Microsoft.Extensions.Http.Diagnostics: .AddExtendedHttpClientLogging() |
| Your own logging or correlation | Your own DelegatingHandler: .AddHttpMessageHandler<MyHandler>() |
| Retries, timeouts, circuit breaker | Microsoft.Extensions.Http.Resilience: .AddStandardResilienceHandler() |
// dotnet add package NinjaVault.Http
builder.Services.AddNinjaVaultHttp(builder.Configuration); // optional: binds NinjaVault:ExternalApiLogging
builder.Services.AddNinjaVaultCdn(builder.Configuration)
.AddNinjaVaultHttpLogging(DependencyInjection.ServiceName) // logging + correlation id
.AddStandardResilienceHandler(); // retries
If you write your own handler, remember that every CDN request carries the
X-Api-Keyheader. Redact it before logging headers. NinjaVault.Http does this for you.
Unit testing your code
Depend on INinjaVaultCdnClient and mock it with any library:
var cdn = Substitute.For<INinjaVaultCdnClient>();
cdn.BuildPublicUrl("public-assets", "logo.png").Returns("https://cdn.test/public/public-assets/logo.png");
var service = new BrandingService(cdn);
Troubleshooting
| Symptom | Fix |
|---|---|
Public URLs contain /public/public/ |
Set PublicBaseUrl to the host only (https://cdn.example.com), or remove it. |
ArgumentException: BaseUrl / ApiKey |
The NinjaVault:Cdn section wasn't found. Check the section name and your environment's appsettings file. |
40101 on every call |
Wrong or rotated key. Check the environment variable NinjaVault__Cdn__ApiKey. |
40001 on upload |
Invalid FolderPath (max 8 segments of [A-Za-z0-9_-], each starting with a letter or digit), or a file type/size the deployment doesn't allow. |
| File not found after upload | Fetch with the returned ObjectKey, not the original file name. |
Compatibility
| Target frameworks | net8.0, net9.0, net10.0 |
| Dependencies | Microsoft.Extensions.Http, Options.ConfigurationExtensions 8.0+ (nothing else) |
| Source Link / symbols | ✅ Step into the package source while debugging |
NinjaVault SDKs
The same CDN client in every language, with the same features and the same version number (for example 100.42.1 everywhere):
| Language | Package | Install | Source |
|---|---|---|---|
| .NET 8+ | NinjaVault.Cdn |
dotnet add package NinjaVault.Cdn |
NinjaVault |
| Python 3.10+ | ninjavault-cdn |
pip install ninjavault-cdn |
NinjaVault-Python |
| Node.js 18+ | @ninjavault/cdn |
npm install @ninjavault/cdn |
NinjaVault-Node |
Links
- Source, issues and release notes: https://github.com/alisaivi786/NinjaVault
- Change-sets: https://github.com/alisaivi786/NinjaVault/tree/main/changesets/NinjaVault.Cdn
- License: MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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
- Microsoft.Extensions.Configuration.Abstractions (>= 8.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
-
net8.0
- Microsoft.Extensions.Configuration.Abstractions (>= 8.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
-
net9.0
- Microsoft.Extensions.Configuration.Abstractions (>= 8.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.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.
| Version | Downloads | Last Updated |
|---|---|---|
| 100.42.1 | 424 | 9/28/2026 |
| 100.42.0.1 | 92 | 9/28/2026 |
| 100.41.0.2 | 100 | 9/28/2026 |
| 100.41.0.1 | 107 | 9/28/2026 |