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.

Upload Architecture
ImageUploadController (Profile photos, logos)
UploadsController (General files)
ImageService (Resize, crop, format)
FileService (Validation, storage)
IBlobStorage (Local / Azure / S3 / MinIO)
StaticFileMiddleware (Serves from storage)

Resumable & Chunked Upload Pipeline

Large files can be uploaded in chunked parts with checksum verification at each chunk, reassembled upon completion directly in storage.

File Chunk Assembly & Processing
Initialize Session (POST /api/uploads/initialize)
Generate Upload ID & Temp File Path
Upload Chunks (POST /api/uploads/{id}/chunk)
Write/Append to Temp File (FileMode.Append)
Complete Session (POST /api/uploads/{id}/complete)
Validate Magic Bytes & Size
Save to IBlobStorage
Clean Up Temp File
UploadService.csC#
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

ImageService.csC#
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.

ProviderConfiguration KeyDetails
Local DiskProvider: "local"Saves files to ContentRootPath/FileHost
AWS S3Provider: "AwsS3"Uses AWSSDK.S3 to upload to configured bucket
Azure BlobProvider: "AzureBlob"Uses Azure.Storage.Blobs client SDK
MinIOProvider: "MinIO"S3-compatible API client configured locally

File Validation Rules

RuleDefaultConfigurable / Enforcement
Image max size10 MBFileSettings.MaxFileSizeMb
Document max size100 MBFileSettings.MaxFileSizeMb
Allowed image typesJPEG, PNG, WebP, GIFFileSettings.AllowedExtensions
Allowed document typesPDF, DOC, DOCX, XLS, XLSX, PPTXFileSettings.AllowedExtensions
Magic-byte validationPNG, JPEG, GIF, PDF, WebP headersEnforced on save to prevent malicious files (S0.12)
SVG SanitizationRemove script, iframe, onload, javascript: URIEnforced for SVG/XML types (S0.11)
Tenant-scoped isolationStored in directory uploads/{tenant-id}/Enforced on save

General File Upload

FileService.csC#
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

Program.csC#
// 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

Storage Directory StructureText
uploads/
└── {tenant-id}/
    ├── admins/{admin-id}/profile.webp
    ├── logos/tenant-logo.png
    └── documents/{file-id}.pdf