In my Old School Runescape blog website lilgobguides, there are two different mechanisms for file uploads which I developed recently, and so I wanted to review and compare them. The first is a normal file upload control in a form for Post
s, and there is a Trix editor for Post
s which does file uploads and deletes. The image data is stored on the server differently in each case. I also include a snippet at the end for a third way to store image uploads, taken from a different project.
Normal form with file upload
For an HTML form or <input>
to be able to upload a file, the enctype
attribute must be set to "multipart/form-data"
(see HTMLFormElement: enctype property)
<form method="post" enctype="multipart/form-data">
Then in the form there is this input for file upload:
<label for="HeaderImageFile" class="form-label">Post Card Header Image</label>
<input type="file" class="form-control" id="HeaderImageFile" name="HeaderImageFile" />
The page model (like a page controller) has a public property with a public property setter (set
instead of private set;
) which gets an IFormFile
from model binding:
[Authorize]
public class NewPostModel(AppDbContext db) : PageModel
{
...
[BindProperty]
public IFormFile? HeaderImageFile { get; set; }
...
}
Then there is some logic in the POST action methods for new post and edit post, this is what adds the image to the Post
object in the new post handler:
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
if (HeaderImageFile != null && HeaderImageFile.Length > 0)
{
using var ms = new MemoryStream();
await HeaderImageFile.CopyToAsync(ms);
Post.HeaderImageData = ms.ToArray();
Post.HeaderImageContentType = HeaderImageFile.ContentType;
}
...
}
The edit post logic has this:
if (HeaderImageFile != null && HeaderImageFile.Length > 0)
{
using var ms = new MemoryStream();
await HeaderImageFile.CopyToAsync(ms);
post.HeaderImageData = ms.ToArray();
post.HeaderImageContentType = HeaderImageFile.ContentType;
}
else
{
_db.Entry(post).Property(p => p.HeaderImageData).IsModified = false;
_db.Entry(post).Property(p => p.HeaderImageContentType).IsModified = false;
}
This makes it so if a post is edited without uploading a new file, the existing file data will not be removed when await _db.SaveChangesAsync()
happens.
Post
has these two properties:
public byte[]? HeaderImageData { get; set; }
public string? HeaderImageContentType { get; set; }
Post
is also an EF Core entity type that has a table Posts
in the SQLite database using EF Core.
public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext(options)
{
public DbSet<Post> Posts { get; set; }
...
}
Thus there is this table (from the sqlite_master
SQLite system catalog):
type name tbl_name rootpage sql
table Posts Posts 29 CREATE TABLE "Posts" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Posts" PRIMARY KEY,
"Content" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"Title" TEXT NOT NULL
, "HeaderImageContentType" TEXT NULL, "HeaderImageData" BLOB NULL, "Featured" INTEGER NOT NULL DEFAULT 0)
So what this shows is the file upload data is being stored as the data value in the SQLite database in a column with the BLOB
type. An SQLite database is a single on-disk database file.
To use that image in the HTML, the Razor pages <img>
gets a src
like so:
@if (Model.HeaderImageData != null && Model.HeaderImageContentType != null)
{
var base64 = Convert.ToBase64String(Model.HeaderImageData);
var imgSrc = $"data:{Model.HeaderImageContentType};base64,{base64}";
<img src="@imgSrc" class="card-img-top" alt="Header Image" />
}
The data: URLS MDN article explains the src
syntax there.
Trix editor file uploads and deletes
With Trix and <trix-editor>
setup, there are some Trix JavaScript events that event listeners can add fetch
calls to:
document.addEventListener("trix-attachment-remove", function (event) {
const attachment = event.attachment;
const url = attachment.getAttribute("url");
if (url) {
fetch("/uploads/trix?url=" + encodeURIComponent(url), {
method: "DELETE"
}).catch(err => console.error("Delete failed:", err));
}
});
document.addEventListener("trix-attachment-add", function (event) {
const attachment = event.attachment;
if (attachment.file) {
uploadTrixFile(attachment);
}
});
function uploadTrixFile(attachment) {
const file = attachment.file;
const formData = new FormData();
formData.append("file", file);
fetch("/uploads/trix", {
method: "POST",
body: formData
})
.then(response => {
if (!response.ok) throw new Error("Upload failed.");
return response.json();
})
.then(data => {
attachment.setAttributes({
url: data.url,
href: data.url
});
})
.catch(error => {
console.error("Upload error:", error);
});
}
To me, the simplest way to handle that on the server is with an API controller. Although lilgobguides
is a Razor pages app, controllers can be added by registering their services and mapping their routes with builder.Services.AddControllers();
and app.MapControllers();
in Program.cs
.
With those services in place, the specific controller for handling the Trix editor events’ fetch
requests is:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace lilgobguides.Controllers;
[ApiController, Route("uploads")]
public class UploadsController(IWebHostEnvironment env) : ControllerBase
{
private readonly IWebHostEnvironment _env = env;
[Authorize, HttpDelete("trix")]
public IActionResult DeleteImage([FromQuery] string url)
{
string fileName = Path.GetFileName(url);
string path = Path.Combine(_env.WebRootPath, "uploads", fileName);
if (System.IO.File.Exists(path))
System.IO.File.Delete(path);
return Ok();
}
[Authorize, HttpPost("trix")]
public async Task<IActionResult> UploadImage(IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest("No file uploaded.");
string uploadsPath = Path.Combine(_env.WebRootPath, "uploads");
Directory.CreateDirectory(uploadsPath);
string fileName = Path.GetRandomFileName() + Path.GetExtension(file.FileName);
string filePath = Path.Combine(uploadsPath, fileName);
await using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
string fileUrl = Url.Content($"~/uploads/{fileName}");
return Ok(new { url = fileUrl });
}
}
[Authorize]
enforces simple authorization (authentication) on both controller actions. [ApiController]
does quite a lot including automatic 400 responses for requests failing model binding and conventional routing based on the class name.
This shows the images uploaded from the Trix editor are saved on the disk in the public web wwwroot
folder with randomly generated file names, making them static assets of the site. In the other case, they were stored as BLOB
values in the SQLite database with image data written into the HTML during server HTML rendering.
An alternative to saving the images in wwwroot
would be to store them in a different folder, and use a controller to serve them from there. I used this approach in a quick project once about a year ago:
[ApiController, Route("api/[controller]")]
public class UploadedImagesController(ILogger<UploadedImagesController> logger,
IConfiguration config,
GalleryDbContext dbContext) : ControllerBase
{
private readonly ILogger<UploadedImagesController> _logger = logger;
private readonly IConfiguration _config = config;
private readonly GalleryDbContext _dbContext = dbContext;
[HttpGet("{imageId}")]
public IActionResult Get(string imageId)
{
UploadedImage? image = _dbContext.UploadedImages.FirstOrDefault(file => file.Id == imageId);
if (image == null) return NotFound();
string uploadedFilesPath = _config.GetValue<string>("StoredFilesPath")!;
string uploadedFilePath = image.PathToFile(uploadedFilesPath);
if (!System.IO.File.Exists(uploadedFilePath)) return NotFound();
return PhysicalFile(uploadedFilePath, "image/png");
}
}