Cloud uploads · v2.0
Send uploaded images and files to S3, Azure Blob Storage, Cloudinary, or any custom backend by implementing
IRichTextBoxUploadStore. The default is local disk —
replace it with one DI registration. Reference implementations below.
The interface
One interface, two methods. The endpoint validates everything (license, extensions, size limits, folder traversal); the store only handles “where do these bytes go and what URL do I return.”
public interface IRichTextBoxUploadStore
{
Task<string> SaveAsync(UploadStoreRequest request, CancellationToken ct = default);
Task DeleteAsync(string webPath, CancellationToken ct = default);
}
public sealed class UploadStoreRequest
{
public string Folder { get; init; } // "" or "reports/2026"
public string FileName { get; init; } // "logo-9c4f2e8a.png"
public string ContentType { get; init; } // "image/png"
public Stream Content { get; init; } // rewindable, position 0
public long ContentLength { get; init; }
public RichTextBoxOptions Options { get; init; }
}
Register your custom store after AddRichTextBox() — the default LocalDiskUploadStore is registered with TryAdd, so any explicit registration wins.
builder.Services.AddRichTextBox();
builder.Services.AddSingleton<IRichTextBoxUploadStore, MyS3UploadStore>();
Amazon S3
Add the AWS SDK to your project, then drop in the store below.
dotnet add package AWSSDK.S3
using Amazon.S3;
using Amazon.S3.Model;
using RichTextBox.Uploads;
public sealed class S3UploadStore : IRichTextBoxUploadStore
{
private readonly IAmazonS3 _s3;
private readonly S3UploadStoreOptions _opts;
public S3UploadStore(IAmazonS3 s3, IOptions<S3UploadStoreOptions> opts)
{
_s3 = s3;
_opts = opts.Value;
}
public async Task<string> SaveAsync(UploadStoreRequest req, CancellationToken ct = default)
{
var key = string.IsNullOrEmpty(req.Folder)
? req.FileName
: $"{req.Folder}/{req.FileName}";
await _s3.PutObjectAsync(new PutObjectRequest
{
BucketName = _opts.Bucket,
Key = key,
InputStream = req.Content,
ContentType = req.ContentType,
CannedACL = S3CannedACL.PublicRead // or use signed URLs
}, ct);
return _opts.PublicBaseUrl is null
? $"https://{_opts.Bucket}.s3.amazonaws.com/{key}"
: $"{_opts.PublicBaseUrl.TrimEnd('/')}/{key}";
}
public async Task DeleteAsync(string webPath, CancellationToken ct = default)
{
var key = ExtractKeyFromUrl(webPath);
if (key is null) return;
await _s3.DeleteObjectAsync(_opts.Bucket, key, ct);
}
private string? ExtractKeyFromUrl(string url) => /* trim base / parse */ null;
}
public sealed class S3UploadStoreOptions
{
public string Bucket { get; set; } = "";
public string? PublicBaseUrl { get; set; } // e.g. https://cdn.example.com
}
Wire it up in Program.cs:
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
builder.Services.AddAWSService<IAmazonS3>();
builder.Services.Configure<S3UploadStoreOptions>(opts =>
{
opts.Bucket = builder.Configuration["S3:Bucket"];
opts.PublicBaseUrl = builder.Configuration["S3:CdnUrl"]; // optional
});
builder.Services.AddRichTextBox();
builder.Services.AddSingleton<IRichTextBoxUploadStore, S3UploadStore>();
CDN tip. Don’t serve uploads directly from s3.amazonaws.com. Put CloudFront in front, set PublicBaseUrl to the CDN domain, and uploads will resolve through CloudFront automatically.
Azure Blob Storage
dotnet add package Azure.Storage.Blobs
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using RichTextBox.Uploads;
public sealed class AzureBlobUploadStore : IRichTextBoxUploadStore
{
private readonly BlobContainerClient _container;
private readonly AzureBlobUploadStoreOptions _opts;
public AzureBlobUploadStore(BlobServiceClient blobService, IOptions<AzureBlobUploadStoreOptions> opts)
{
_opts = opts.Value;
_container = blobService.GetBlobContainerClient(_opts.Container);
}
public async Task<string> SaveAsync(UploadStoreRequest req, CancellationToken ct = default)
{
var key = string.IsNullOrEmpty(req.Folder)
? req.FileName
: $"{req.Folder}/{req.FileName}";
var blob = _container.GetBlobClient(key);
await blob.UploadAsync(req.Content,
new BlobHttpHeaders { ContentType = req.ContentType },
cancellationToken: ct);
return _opts.PublicBaseUrl is null
? blob.Uri.ToString()
: $"{_opts.PublicBaseUrl.TrimEnd('/')}/{key}";
}
public async Task DeleteAsync(string webPath, CancellationToken ct = default)
{
var key = ExtractKeyFromUrl(webPath);
if (key is null) return;
await _container.DeleteBlobIfExistsAsync(key, cancellationToken: ct);
}
private string? ExtractKeyFromUrl(string url) => /* parse */ null;
}
public sealed class AzureBlobUploadStoreOptions
{
public string Container { get; set; } = "";
public string? PublicBaseUrl { get; set; }
}
Wire it up:
using Azure.Storage.Blobs;
builder.Services.AddSingleton(_ => new BlobServiceClient(
builder.Configuration["AzureBlob:ConnectionString"]));
builder.Services.Configure<AzureBlobUploadStoreOptions>(opts =>
{
opts.Container = builder.Configuration["AzureBlob:Container"];
opts.PublicBaseUrl = builder.Configuration["AzureBlob:CdnUrl"];
});
builder.Services.AddRichTextBox();
builder.Services.AddSingleton<IRichTextBoxUploadStore, AzureBlobUploadStore>();
Public access. Set the container's public access level to “Blob” for direct serving, or front it with Azure CDN / Front Door and use PublicBaseUrl for the CDN domain.
Cloudinary
dotnet add package CloudinaryDotNet
using CloudinaryDotNet;
using CloudinaryDotNet.Actions;
using RichTextBox.Uploads;
public sealed class CloudinaryUploadStore : IRichTextBoxUploadStore
{
private readonly Cloudinary _cloud;
private readonly CloudinaryUploadStoreOptions _opts;
public CloudinaryUploadStore(Cloudinary cloud, IOptions<CloudinaryUploadStoreOptions> opts)
{
_cloud = cloud;
_opts = opts.Value;
}
public async Task<string> SaveAsync(UploadStoreRequest req, CancellationToken ct = default)
{
var publicId = string.IsNullOrEmpty(req.Folder)
? Path.GetFileNameWithoutExtension(req.FileName)
: $"{req.Folder}/{Path.GetFileNameWithoutExtension(req.FileName)}";
var isImage = req.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
if (isImage)
{
var result = await _cloud.UploadAsync(new ImageUploadParams
{
File = new FileDescription(req.FileName, req.Content),
PublicId = publicId,
Folder = _opts.RootFolder,
Overwrite = false,
});
return result.SecureUrl.ToString();
}
else
{
var result = await _cloud.UploadAsync(new RawUploadParams
{
File = new FileDescription(req.FileName, req.Content),
PublicId = publicId,
Folder = _opts.RootFolder,
});
return result.SecureUrl.ToString();
}
}
public async Task DeleteAsync(string webPath, CancellationToken ct = default)
{
var publicId = ExtractPublicIdFromUrl(webPath);
if (publicId is null) return;
await _cloud.DestroyAsync(new DeletionParams(publicId));
}
private string? ExtractPublicIdFromUrl(string url) => /* parse */ null;
}
public sealed class CloudinaryUploadStoreOptions
{
public string? RootFolder { get; set; } // e.g. "rtb-uploads"
}
Wire it up:
using CloudinaryDotNet;
builder.Services.AddSingleton(_ => new Cloudinary(builder.Configuration["Cloudinary:Url"]));
builder.Services.Configure<CloudinaryUploadStoreOptions>(opts =>
{
opts.RootFolder = "rtb-uploads";
});
builder.Services.AddRichTextBox();
builder.Services.AddSingleton<IRichTextBoxUploadStore, CloudinaryUploadStore>();
Cloudinary's superpower. Once an image is in Cloudinary you can transform it on the URL: w_320,c_fill,q_auto,f_auto/<publicId> for an automatic 320px thumbnail. Useful for editor previews.
Google Cloud Storage
dotnet add package Google.Cloud.Storage.V1
using Google.Cloud.Storage.V1;
using RichTextBox.Uploads;
public sealed class GcsUploadStore : IRichTextBoxUploadStore
{
private readonly StorageClient _client;
private readonly GcsUploadStoreOptions _opts;
public GcsUploadStore(StorageClient client, IOptions<GcsUploadStoreOptions> opts)
{
_client = client;
_opts = opts.Value;
}
public async Task<string> SaveAsync(UploadStoreRequest req, CancellationToken ct = default)
{
var key = string.IsNullOrEmpty(req.Folder)
? req.FileName
: $"{req.Folder}/{req.FileName}";
await _client.UploadObjectAsync(_opts.Bucket, key, req.ContentType, req.Content, cancellationToken: ct);
return _opts.PublicBaseUrl is null
? $"https://storage.googleapis.com/{_opts.Bucket}/{key}"
: $"{_opts.PublicBaseUrl.TrimEnd('/')}/{key}";
}
public async Task DeleteAsync(string webPath, CancellationToken ct = default)
{
var key = ExtractKeyFromUrl(webPath);
if (key is null) return;
await _client.DeleteObjectAsync(_opts.Bucket, key, cancellationToken: ct);
}
private string? ExtractKeyFromUrl(string url) => /* parse */ null;
}
What the endpoint still does
Even with a custom store, the editor's upload endpoint continues to enforce:
- License validation — rejects uploads without a valid
RichTextBox.lic.
- File-extension allow-list — configurable via
options.AllowedImageExtensions / AllowedFileExtensions.
- Magic-byte verification — the first bytes of every upload must match the declared extension; opt out via
options.ValidateUploadMagicBytes = false.
- Size limit —
options.MaxUploadBytes (default 4 MB), with optional per-extension overrides via options.MaxUploadBytesByExtension.
- Folder traversal defense —
NormalizeFolderPath strips ../, invalid filename chars, and absolute paths before the store ever sees the folder argument.
- File-name disambiguation — appends a
Guid.NewGuid() stem so two uploads with the same original name don't collide.
Stores receive a clean, validated request and only need to write bytes + return a URL.
Hybrid: local for documents, cloud for images
Compose two stores: route based on the content type, MIME, or folder.
public sealed class HybridUploadStore : IRichTextBoxUploadStore
{
private readonly LocalDiskUploadStore _local;
private readonly S3UploadStore _s3;
public HybridUploadStore(LocalDiskUploadStore local, S3UploadStore s3)
{
_local = local;
_s3 = s3;
}
public Task<string> SaveAsync(UploadStoreRequest req, CancellationToken ct = default)
=> req.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
? _s3.SaveAsync(req, ct)
: _local.SaveAsync(req, ct);
public Task DeleteAsync(string webPath, CancellationToken ct = default)
=> webPath.Contains("s3.amazonaws.com", StringComparison.OrdinalIgnoreCase)
? _s3.DeleteAsync(webPath, ct)
: _local.DeleteAsync(webPath, ct);
}
Need a different backend?
Implement IRichTextBoxUploadStore against any storage system — SFTP, MinIO, internal file server, content-addressed blob store. Same two methods, same wire-up.