Uploads
Serenity has a complete upload infrastructure for handling file and image uploads in your row-based services. It covers storing the file, validating it, optionally processing images (scaling/thumbnails), moving the temporary file to its permanent location on save, deleting old files, and serving the file back to the client.
How Uploads Work
Uploads follow a two-phase flow so that a file isn't committed to permanent storage until the row is actually saved:
- Temporary upload. The client-side upload editor posts the file to a temporary upload endpoint (
/File/TemporaryUpload). The endpoint uses IUploadProcessor to validate the file and write it to the temporary folder, returning a temporary path (prefixedtemporary/). The row field is set to this temporary path. - Save. When the row is saved, the FileUploadBehavior moves the temporary file to its permanent location, writes the permanent path into the field, and deletes (or archives) the previous file. Old temporary files are purged periodically.
Files are served back through IUploadFileResponder, which exposes files at /upload/{path}.
Declaring an Upload Field
Decorating a String field on your row (not the form) with an upload editor attribute is enough to enable uploads. The attribute provides the editor type for the client and the options used by FileUploadBehavior.
[DisplayName("User Image"), Size(100)]
[ImageUploadEditor(FilenameFormat = "UserImage/~", CopyToHistory = true)]
public string? UserImage { get => fields.UserImage[this]; set => fields.UserImage[this] = value; }
The Northwind sample uses the same pattern:
[DisplayName("Product Image"), Size(100)]
[ImageUploadEditor(FilenameFormat = "ProductImage/~", CopyToHistory = true)]
public string? ProductImage { get => fields.ProductImage[this]; set => fields.ProductImage[this] = value; }
Upload editor attributes
| Attribute | Editor type | Multiple? | Description |
|---|---|---|---|
| ImageUploadEditor | ImageUpload |
No | Allows only image files by default |
| FileUploadEditor | ImageUpload |
No | Allows image and non-image files |
| MultipleImageUploadEditor | MultipleImageUpload |
Yes | Multiple images; stores a JSON array |
| MultipleFileUploadEditor | MultipleImageUpload |
Yes | Multiple files; stores a JSON array |
Important: put the attribute in
Row.cs, notForm.cs. TheFileUploadBehavioronly runs when the attribute is on the field, so if you only put it on the form your files will stay in the temporary folder.
Common options
All upload editor attributes derive from BaseUploadEditorAttribute, which exposes options such as:
FilenameFormat— folder/file naming, e.g."UserImage/~".OriginalNameProperty— the name of another field in the row that stores the original file name.MaxSize/MinSize(bytes),MaxWidth/MaxHeight/MinWidth/MinHeight.AllowNonImage,ImageExtensions,AllowedExtensions.ScaleWidth/ScaleHeight,ScaleSmaller,ScaleMode,ScaleQuality,ScaleBackColor.ThumbWidth/ThumbHeight,ThumbSizes,ThumbMode,ThumbQuality,ThumbBackColor.CopyToHistory— archive the previous file instead of deleting it.JsonEncodeValue— multiple editors store the file list as a JSON array.
For multiple uploads, each item in the stored JSON array is an UploadedFile with Filename and OriginalName. The FilenameFormat pattern is expanded by UploadFormatting (FormatFilename), which substitutes the entity ID, group key, random code, date, and original file name.
Upload Storage
IUploadStorage abstracts where files live. It provides methods to write, read, delete, and copy files, get file URLs and sizes, and manage file metadata.
The default implementation, DefaultUploadStorage, is disk-based and writes files under App_Data/upload/. Related implementations include DiskUploadStorage, TempUploadStorage, and CombinedUploadStorage. DiskUploadStorage writes through PhysicalDiskUploadFileSystem (an IDiskUploadFileSystem implementation over the physical file system).
DefaultUploadStorage itself is a CombinedUploadStorage of two DiskUploadStorage instances: a permanent storage rooted at UploadSettings.Path (default App_Data/upload/) served at UploadSettings.Url (default /upload/), and a temporary storage under <path>/temporary served at <url>/temporary/. The temporary prefix is what lets the framework distinguish temporary files (from the upload editor) from permanent ones, and is what FileUploadBehavior checks before copying a file into place.
UploadPathHelper contains path utilities (thumbnail names, security checks), and UploadStorageExtensions provides helpers such as CopyTemporaryFile and GetThumbnailUrl.
Storage is registered by AddUploadStorage():
public static IServiceCollection AddUploadStorage(this IServiceCollection collection)
{
collection.TryAddSingleton<IFilenameFormatSanitizer, DefaultFilenameFormatSanitizer>();
collection.TryAddSingleton<IUploadStorage, DefaultUploadStorage>();
collection.TryAddSingleton<IUploadValidator, DefaultUploadValidator>();
collection.TryAddSingleton<IImageProcessor, DefaultImageProcessor>();
collection.TryAddSingleton<IUploadProcessor, DefaultUploadProcessor>();
collection.TryAddSingleton<IUploadFileResponder, DefaultUploadFileResponder>();
return collection;
}
Processing and Validation
The pipeline is handled by IUploadProcessor, whose default implementation does the following when a file is posted:
- Security check on the file name (via
UploadPathHelper.CheckFileNameSecurity). - Optional antivirus scan through IUploadAVScanner.
- Validation with IUploadValidator — checks file size and allowed extensions, and image constraints (dimensions).
- Write the temporary file to storage.
- Image processing with IImageProcessor — scale the main image and generate thumbnails if options specify sizes.
- Returns a
ProcessedUploadInfowhoseTemporaryFileis the new temporary path.
If anything fails, the partially written temporary file is cleaned up and the exception is thrown (so the client shows a validation error).
The old
UploadProcessorclass is obsolete — inject and useIUploadProcessorinstead.
Image processing
DefaultImageProcessor uses ImageChecker to validate image content and ThumbnailGenerator to create thumbnails. ImageChecker returns an ImageCheckResult code describing why an image was rejected (e.g. InvalidImage, WidthTooHigh, SizeMismatch), and ImageEncoderParams carries the JPEG quality used when encoding. The scaling options (ScaleWidth, ScaleHeight, ThumbWidth, etc.) on the editor attribute control what happens.
Configuration
UploadSettings are read from the UploadSettings section of appsettings.json:
{
"UploadSettings": {
"Path": "App_Data/upload/",
"Url": "~/upload/",
"ExtensionBlacklistInclude": ".exe;.dll;",
"ExtensionWhitelistExclude": ".zip;"
}
}
Path— the root folder for uploads (defaultApp_Data/upload/).Url— the public URL prefix (default~/upload/).ExtensionBlacklist/ExtensionWhitelist(plus*Include/*Excludevariants) — control which file extensions are allowed.EditableMetadataKeys— metadata keys clients are allowed to set.
Deleting and Archiving Files
When a field value changes, FileUploadBehavior registers the old file for deletion through FilesToDelete (via UnitOfWork.RegisterFilesToDelete), so the file is removed only if the transaction commits. If CopyToHistory is set, the old file is archived instead of deleted. The same happens on row delete (OnAfterDelete), unless the row uses soft delete.
FilesToDelete implements IFilesToDelete, which tracks new and old files. FilesToDeleteExtensions provides RegisterFilesToDelete, which hooks the container into the unit of work so old files are deleted on commit and new files on rollback. FileMetadataKeys defines the metadata keys stored alongside files (e.g. OriginalName, EntityId, ImageSize).
Temporary → Permanent Copy
When a row is saved, FileUploadBehavior moves the temporary file to its permanent location. The copy is driven by UploadStorageExtensions.CopyTemporaryFile, which takes a CopyTemporaryFileOptions (the temporary file, the target FilenameFormat, and an IFilesToDelete container) and returns a CopyTemporaryFileResult with the new path, original name, thumbnail flag, and file size. FileUploadBehavior also writes file metadata (entity table, type, field, property, and ID) via SetFileMetadata.
The OverwriteOption controls what happens when a file already exists at the target path:
| Value | Behavior |
|---|---|
Disallowed |
Raise an error |
Overwrite |
Overwrite the target file |
AutoRename |
Find a suitable new name for the source file |
File Read Access
Uploaded files are served through /upload/{path}. By default, access to a file is controlled by the read permission of the entity that owns it. You can refine this with file read access attributes on the upload field.
Note: The file read access checks described in this section are enforced by
SecureUploadFileResponder, which is provided by Serenity.Pro.Extensions (a premium package). It is available in StartSharp but not in Serene. The defaultDefaultUploadFileResponder(in the open-source framework) serves files without checking these attributes. In StartSharp,SecureUploadFileResponderis registered as theIUploadFileResponderviaAddSecureUploadFileResponder()inStartup.cs:services.AddSecureUploadFileResponder();
[FileReadPermission]
FileReadPermissionAttribute specifies a permission required to read the file for a field:
[ImageUploadEditor(FilenameFormat = "UserImage/~")]
[FileReadPermission("Administration:Security")]
public string? UserImage { get; set; }
[FileReadAccess]
FileReadAccessAttribute is the base attribute with more options:
Permission— the permission to check. Use"*"for public access,"?"for any logged-in user, or a specific key. IfLogicOperatorPermissionServiceis registered, this can also be a logical expression likeA|B&!C.AllowBypass— whether users with the bypass permission (see settings below) are allowed to read the file. Defaulttrue.PermissionOnly— skip entity-level access control and use only permission-based access.
FileReadAccessSettings
FileReadAccessSettings configures file read access from the FileReadAccess section of appsettings.json:
{
"FileReadAccess": {
"BypassPermission": "Administration:General",
"DefaultPermission": "*",
"PathPermissions": "^public/:*;^temporary/:*"
}
}
BypassPermission— a permission that bypasses all file read access checks (e.g. give administrators access to all files).DefaultPermission— the permission to check when no[FileReadAccess]attribute is present (default"*").MissingMetadataPermission— permission to check when file metadata is missing.PathPermissions— regex patterns for paths and their permissions, evaluated in order (default allows public access topublic/andtemporary/).EnableAccessLogging— log access control decisions for debugging.ReturnForbidResult— return a 403 instead of the default 404 when access is denied.
Serving Uploaded Files
Uploaded files are served through an IUploadFileResponder, which reads a file from the upload storage and writes it to the HTTP response.
IUploadFileResponder
IUploadFileResponder is the abstraction for reading a file via the /upload/{path} route:
public interface IUploadFileResponder
{
IActionResult Read(string pathInfo, IHeaderDictionary responseHeaders);
}
It takes the path from the URL and the response headers, and returns an IActionResult based on the file's MIME type.
DefaultUploadFileResponder
DefaultUploadFileResponder is the default implementation. Its Read method:
- Checks the path for security (
UploadPathHelper.CheckFileNameSecurity). - Returns
404if the file doesn't exist in the upload storage. - Determines the MIME type via
KnownMimeTypes.Get(path). - Adds an
X-Content-Type-Options: nosniffheader. - For PDFs, plain text, and images, returns the file inline (
FileStreamResultwith the detected MIME type). - For everything else, returns the file as an attachment (
application/octet-streamwith aContent-Dispositionheader), so the browser downloads it instead of rendering it.
The /upload route
The route is defined by the template's FilePage controller, which delegates to the responder:
public class FilePage(IUploadStorage uploadStorage, IUploadProcessor uploadProcessor) : Controller
{
[Route("upload/{*pathInfo}")]
public IActionResult Read(string pathInfo,
[FromServices] IUploadFileResponder responder)
{
return responder.Read(pathInfo, Response.Headers);
}
}
So a stored file with path product/00001/00000001_abc.jpg is served at /upload/product/00001/00000001_abc.jpg.
KnownMimeTypes
KnownMimeTypes is a static helper for determining the MIME type of a file from its extension:
Get(path)— returns the MIME type, orapplication/octet-streamif unknown.TryGet(path)— returns the MIME type, ornullif unknown.
It uses ASP.NET Core's FileExtensionContentTypeProvider for the standard mappings, plus a small set of additional known types (e.g. .apng, .avif). This is what DefaultUploadFileResponder uses to decide whether to serve a file inline or as a download.