Cloud upload providers
Send uploaded images and files to S3, Azure Blob Storage, Cloudinary, or any custom backend by implementingIRichTextBoxUploadStore. 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 0public 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>();
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>();
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>();
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 viaoptions.MaxUploadBytesByExtension. - Folder traversal defense —
NormalizeFolderPathstrips../, 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.