Kyle Rain

Razor pages file upload model binding with EF Core

Today I wanted to add an image upload to my post model in a Razor pages ASP.NET Core blog app to be the header image of the post’s card styled with Bootstrap.

As this would be a single image for the post, I thought it would suffice to store the uploaded image data just in the SQLite database as part of the post record. That can be accomplished by adding a byte[] property of the Post model and then scaffolding migrations to add that to the database provider, in my case SQLite database, to add that column to the Posts table:

using System.ComponentModel.DataAnnotations;

namespace lilgobguides.Models;

public class Post
{
    [Key]
    public string Id { get; set; } = Guid.NewGuid().ToString();
    [Required]
    public string Title { get; set; } = null!;
    public string Content { get; set; } = null!;
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    public PostCategorization Categorization { get; set; } = new();

    public Post()
    {
        Categorization.PostId = Id;
    }

    public byte[]? HeaderImageData { get; set; }

    // (e.g. "image/png")
    public string? HeaderImageContentType { get; set; }
}

The last two properties in the type are what was added for this feature. The auto-scaffolded migration from dotnet ef migrations add AddHeaderImageData or similar:

using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace lilgobguides.Migrations
{
    /// <inheritdoc />
    public partial class AddHeaderImageData : Migration
    {
        /// <inheritdoc />
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.AddColumn<string>(
                name: "HeaderImageContentType",
                table: "Posts",
                type: "TEXT",
                nullable: true);

            migrationBuilder.AddColumn<byte[]>(
                name: "HeaderImageData",
                table: "Posts",
                type: "BLOB",
                nullable: true);
        }

        /// <inheritdoc />
        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropColumn(
                name: "HeaderImageContentType",
                table: "Posts");

            migrationBuilder.DropColumn(
                name: "HeaderImageData",
                table: "Posts");
        }
    }
}

So this shows the byte[] type in C# is mapped to a BLOB type in the SQLite database.

To do the image upload, the form has to have the enctype="multipart/form-data" attribute-value pair:

<form method="post" enctype="multipart/form-data">
    @await Html.PartialAsync("_PostForm", Model.Post)
    <div class="mt-4">
      <button type="submit" class="btn btn-primary">Create post</button>
    </div>
    
  </form>

This is the form upload control in _PostForm that model binding to the page model properties will happen:

<div class="mt-2">
    <label for="HeaderImageFile" class="form-label">Header Image</label>
    <input type="file" class="form-control" id="HeaderImageFile" name="HeaderImageFile" />
</div>

The page model for the page with this needs a property to bind the incoming IFormFile:

[BindProperty]
public IFormFile? HeaderImageFile { get; set; }

The POST action method handling the form submission can then use the value bound to that property (ModelState.IsValid is checking the state of model validation which happens after model binding):

public async Task<IActionResult> OnPostAsync(string id)
{
    if (!ModelState.IsValid)
    {
        Console.WriteLine("Model validation failed");
        return Page();
    }

    Post? post = await _db.Posts
                        .Include(p => p.Categorization)
                        .Where(p => p.Id == id)
                        .FirstOrDefaultAsync();

    if (post == null) return NotFound();

    post.Categorization ??= new() { PostId = Post.Id };

    post.Title = Post.Title;
    post.Content = Post.Content;
    post.Categorization.Skilling = Post.Categorization.Skilling;
    post.Categorization.Minigame = Post.Categorization.Minigame;
    post.Categorization.Item = Post.Categorization.Item;
    post.Categorization.Boss = Post.Categorization.Boss;

    if (HeaderImageFile != null && HeaderImageFile.Length > 0)
    {
        using var ms = new MemoryStream();
        await HeaderImageFile.CopyToAsync(ms);
        post.HeaderImageData = ms.ToArray();
        post.HeaderImageContentType = HeaderImageFile.ContentType;
    }
    else
    {
        Console.WriteLine("There was no header image");
        _db.Entry(post).Property(p => p.HeaderImageData).IsModified = false;
        _db.Entry(post).Property(p => p.HeaderImageContentType).IsModified = false;
    }

    await _db.SaveChangesAsync();

    return RedirectToPage("/Posts/ShowPost", new { post.Id });
}

And finally this is how the img tag is used:

@using System.Text.RegularExpressions
@model lilgobguides.Models.Post

<div class="card" style="width:20rem" asp-page="/Posts/ShowPost" asp-route-id="@Model.Id">
    @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" />
    }
    <div class="card-body">
        <h5 class="card-title">@Model.Title</h5>
        <p class="card-text">
            @{
                var text = Regex.Replace(Model.Content, "<.*?>", "");
                if (text.Length > 200) {
                    @($"{text.Substring(0,100)}...")
                } else {
                    @text
                }
            }
        </p>
        <a asp-page="/Posts/ShowPost" asp-route-id="@Model.Id" class="btn btn-primary">
            Read more
        </a>
    </div>
</div>

which is a nice looking Bootstrap card (thanks Bootstrap):

Bootstrap card with an image h

This project is maintained by KyleRego