File Upload System
Dual upload pipeline for images and documents with validation, processing, and tenant-scoped storage.
Upload Architecture
SCRIPE has two upload paths: ImageUploadController for profile photos and logos (with resize/crop/format processing), and UploadsController for general files (documents, attachments). Both validate file types and sizes before delegating to IBlobStorage.
Resumable & Chunked Upload Pipeline
Large files can be uploaded in chunked parts with checksum verification at each chunk, reassembled upon completion directly in storage.
public async Task UploadChunkAsync(string uploadId, Stream chunk, long offset, CancellationToken cancellationToken = default)
{
if (!_sessions.TryGetValue(uploadId, out var session))
{
throw new InvalidOperationException($"Upload {uploadId} not found");
}
// Opens or appends chunk data depending on the current offset
await using var fileStream = new FileStream(
session.TempPath,
offset == 0 ? FileMode.Create : FileMode.Append,
FileAccess.Write
);
await chunk.CopyToAsync(fileStream, cancellationToken);
session.UploadedBytes = fileStream.Length;
}Image Upload Pipeline
public class ImageService : IImageService
{
public async Task<string> UploadAsync(IFormFile file, ImageUploadOptions options)
{
// 1. Validate file type (JPEG, PNG, WebP)
ValidateImageType(file);
// 2. Validate file size (max configurable, default 5MB)
ValidateFileSize(file, options.MaxSize);
// 3. Read and process image
using var image = await Image.LoadAsync(file.OpenReadStream());
// 4. Resize if needed (maintains aspect ratio)
if (image.Width > options.MaxWidth || image.Height > options.MaxHeight)
{
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(options.MaxWidth, options.MaxHeight),
Mode = ResizeMode.Max
}));
}
// 5. Save to storage via IBlobStorage
var path = $"{options.Folder}/{Guid.NewGuid()}.webp";
await _blobStorage.UploadAsync(path, imageStream, "image/webp");
return path;
}
}Pluggable Storage Providers
Storage adapts dynamically to the target infrastructure: local disk filesystem for on-premises development, AWS S3, or Azure Blob Storage in cloud deployments.
| Provider | Configuration Key | Details |
|---|---|---|
| Local Disk | Provider: "local" | Saves files to ContentRootPath/FileHost |
| AWS S3 | Provider: "AwsS3" | Uses AWSSDK.S3 to upload to configured bucket |
| Azure Blob | Provider: "AzureBlob" | Uses Azure.Storage.Blobs client SDK |
| MinIO | Provider: "MinIO" | S3-compatible API client configured locally |
File Validation Rules
| Rule | Default | Configurable / Enforcement |
|---|---|---|
| Image max size | 10 MB | FileSettings.MaxFileSizeMb |
| Document max size | 100 MB | FileSettings.MaxFileSizeMb |
| Allowed image types | JPEG, PNG, WebP, GIF | FileSettings.AllowedExtensions |
| Allowed document types | PDF, DOC, DOCX, XLS, XLSX, PPTX | FileSettings.AllowedExtensions |
| Magic-byte validation | PNG, JPEG, GIF, PDF, WebP headers | Enforced on save to prevent malicious files (S0.12) |
| SVG Sanitization | Remove script, iframe, onload, javascript: URI | Enforced for SVG/XML types (S0.11) |
| Tenant-scoped isolation | Stored in directory uploads/{tenant-id}/ | Enforced on save |
General File Upload
public class FileService : IFileService
{
public async Task<string> SaveFileAsync(Stream stream, string fileName, string scheme, string? preferredFileName = null, CancellationToken cancellationToken = default)
{
var settings = GetSettings(scheme);
var extension = Path.GetExtension(fileName).TrimStart('.').ToLowerInvariant();
// 1. Validate extension against whitelist
if (settings.AllowedExtensions != null && !settings.AllowedExtensions.Contains(extension))
throw new InvalidOperationException($"File type '{extension}' is not allowed");
// 2. Validate size
if (stream.Length > settings.MaxFileSizeBytes)
throw new InvalidOperationException($"File size exceeds maximum");
// 3. Magic-byte signature validation (S0.12)
if (stream.CanSeek)
{
var originalPos = stream.Position;
if (!ValidateMagicBytes(stream, extension))
throw new InvalidOperationException("File content does not match declared extension.");
stream.Position = originalPos;
}
// 4. Save to destination
var uniqueFileName = preferredFileName ?? $"{Guid.NewGuid():N}.{extension}";
if (!_blobStorageSettings.Provider.Equals("local", StringComparison.OrdinalIgnoreCase) && IsPublicScheme(scheme))
{
var contentType = GetContentType("." + extension);
return await _blobStorage.UploadAsync(scheme, uniqueFileName, stream, contentType, cancellationToken);
}
var relativePath = Path.Combine(settings.StoragePath!, uniqueFileName);
var absolutePath = GetAbsolutePath(relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)!);
await using var fileStream = new FileStream(absolutePath, FileMode.Create);
await stream.CopyToAsync(fileStream, cancellationToken);
return $"{settings.RequestPath}/{uniqueFileName}";
}
}Static File Serving
// Static file middleware for uploaded files
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(settings.StoragePath),
RequestPath = "/uploads",
ServeUnknownFileTypes = false,
DefaultContentType = "application/octet-stream"
});Uploaded files are served through ASP.NET Core's StaticFileMiddleware. The /uploads request path maps to the physical storage directory. Unknown file types return application/octet-stream to prevent MIME sniffing attacks.
Tenant-Scoped Storage
uploads/
└── {tenant-id}/
├── admins/{admin-id}/profile.webp
├── logos/tenant-logo.png
└── documents/{file-id}.pdf