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.
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>();
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>();
s3.amazonaws.com. Put CloudFront in front, set PublicBaseUrl to the CDN domain, and uploads will resolve through CloudFront automatically.
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>();
PublicBaseUrl for the CDN domain.
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>();
w_320,c_fill,q_auto,f_auto/<publicId> for an automatic 320px thumbnail. Useful for editor previews.
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; }
Even with a custom store, the editor's upload endpoint continues to enforce:
RichTextBox.lic.options.AllowedImageExtensions / AllowedFileExtensions.options.MaxUploadBytes (default 4 MB).NormalizeFolderPath strips ../, invalid filename chars, and absolute paths before the store ever sees the folder argument.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.
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); }
Implement IRichTextBoxUploadStore against any storage system — SFTP, MinIO, internal file server, content-addressed blob store. Same two methods, same wire-up.