Cloud uploads · v2.2

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>();
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.

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.

Toolbar items referenceFull feature list