EasyAdminBlazor 文件上传源码解析:从浏览器到服务器到底发生了什么?

原创 2026-09-28 900 次阅读

已发布的第八篇讲的是文件管理的功能(上传、预览、压缩、WebP)。这一篇把镜头拉近,看一次上传从浏览器到磁盘、再到数据库,中间过了几道关。


一、全景链路

AdminFileInput / FilePicker(选文件)
   ↓
FileService.UploadFileAsync(UploadFile file, ...)
   ↓ 扩展名白名单 / 黑名单
   ↓ 文件大小(MaxSize)
   ↓ Magic Number(FileSecurityValidator)
   ↓ 目录:uploads/{tenantCode}/{yyyy/MM/dd}
   ↓ 文件名规范化 + 重名随机后缀
   ↓ SaveToFileAsync 落盘
   ↓ ProcessImageAsync(ImageSharp 校验 + 缩放 + WebP)
   ↓ 写 SysFile 记录
   ↓ 返回 LinkUrl / api/files/{id}

下面逐段看。


二、入口:组件只管选文件,上传靠 FileService

AdminFileInput 是一个"文件路径输入 + 选择器 + 预览"的组合:

@inherits ValidateBase<string>

@if (IsShowLabel)
{
    <BootstrapLabel required="@Required" for="@Id" ShowLabelTooltip="ShowLabelTooltip" Value="@DisplayText" />
}
<div @attributes="@AdditionalAttributes" id="@Id" class="@ClassString" tabindex="0" hidefocus="true">
    <div class="input-group">
        <input @bind="@CurrentValue" type="text" class="form-control" placeholder="" maxlength="255">
        <button class="btn btn-outline-secondary mr-3" type="button" onclick="window.openfilepicker((url)=>{window.setpickervalue(this,url);})"><i class="fa-regular fa-folder-open"></i>@CommonLocalizer["选择..."]</button>
        @if (!string.IsNullOrEmpty(Value))
        {
            <button class="btn btn-outline-secondary mr-3" type="button" @onclick="ClearValues">@CommonLocalizer["清除"]</button>
            <Popover Placement="Placement.Left" Title="@DisplayText" style="flex:initial;">
                <ChildContent>
                    <a class="btn btn-outline-secondary" href="@Value" target="_blank" title="@CommonLocalizer["点击查看大图"]">@CommonLocalizer["查看"]</a>
                </ChildContent>
                <Template>
                    <img src="@Value" style="max-width:200px;" />
                </Template>
            </Popover>
        }
    </div>
</div>

三个设计点:

  • 它继承 ValidateBase<string>,所以能参与 BootstrapBlazor 的表单校验([Required] 之类),行为和原生输入框一致;
  • 它只保存路径字符串,真正的上传由 AdminFilePicker 调 FileService 完成;
  • 图片可以直接悬浮预览,非图片走"查看"链接。

三、UploadFileAsync:六道关

1. 扩展名白名单 / 黑名单

var extention = file.GetExtension()?.ToLower();
var hasIncludeExtension = IncludeExtension?.Length > 0;
if (hasIncludeExtension && !IncludeExtension.Contains(extention))
{
    file.Error = string.Format(_commonLocalizer["不允许上传{0}文件格式"], extention);
    return null;
}
var hasExcludeExtension = ExcludeExtension?.Length > 0;
if (hasExcludeExtension && ExcludeExtension.Contains(extention))
{
    file.Error = string.Format(_commonLocalizer["不允许上传{0}文件格式"], extention);
    return null;
}

白名单和黑名单是同时生效的:先要求在白名单内,再要求不在黑名单内。看上去冗余,实际上这是一种防御性设计——黑名单可以快速封掉新出现的危险类型,而不用改动白名单语义。

2. 文件大小

private readonly long MaxSize = 10485760;   // 10MB

var fileLenth = file.Size;
if (fileLenth > MaxSize)
{
    file.Error = string.Format(_commonLocalizer["文件大小不能超过{0}"], new FileSize(MaxSize));
    return null;
}

注意 SecurityOptions.MaxUploadSize 默认也是 10MB(10 * 1024 * 1024),业务层与 HTTP 层用同一个量级,避免"两边限制不一致导致超大文件先上传完再被拒"。

3. Magic Number:内容是真的吗

await using (var validationStream = file.File.OpenReadStream(MaxSize))
{
    if (!await FileSecurityValidator.HasAllowedContentAsync(validationStream, extention))
    {
        file.Error = string.Format(_commonLocalizer["文件内容与扩展名{0}不匹配"], extention);
        return null;
    }
}

校验逻辑是"读文件头 + 比对签名":

public static async Task<bool> HasAllowedContentAsync(Stream input, string? extension, CancellationToken cancellationToken = default)
{
    if (string.IsNullOrWhiteSpace(extension)) return false;

    extension = extension.ToLowerInvariant();
    if (extension == ".txt")
    {
        return !await ContainsBinaryBytesAsync(input, cancellationToken);
    }

    if (LegacyOfficeExtensions.Contains(extension))
    {
        return await HasAnyPrefixAsync(input, [new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 }], cancellationToken);
    }

    if (FileSignatures.TryGetValue(extension, out var signatures))
    {
        return await HasAnyPrefixAsync(input, signatures, cancellationToken);
    }

    return false;
}

几个细节:

  • .txt 单独处理:不看签名,而是判断前 1KB 里有没有 0x00(二进制字节),有就拒绝,避免"文本后缀塞二进制";
  • 老版 Office(.doc / .xls / .ppt)共用复合文档签名 D0 CF 11 E0 A1 B1 1A E1;
  • 未知扩展名直接返回 false(fail-closed),而不是"没有签名就放行"。

4. 路径与文件名

if (fileDirectory.IsNull())
{
    fileDirectory = Path.Combine(DirectoryName, TenantPrefix);
    if (DateTimeDirectory.NotNull())
    {
        fileDirectory = Path.Combine(fileDirectory, DateTime.Now.ToString(DateTimeDirectory)).ToPath();
    }
}
else
{
    fileDirectory = Path.Combine(DirectoryName, TenantPrefix, ValidateAndNormalizePath(fileDirectory)).ToPath();
}

目录由三部分组成:

uploads / {tenantCode} / yyyy/MM/dd

租户隔离靠 TenantPrefix:

/// <summary>当前租户 code 前缀(如 "vip/"),无租户时返回空字符串,用于物理隔离文件</summary>
private string TenantPrefix => _adminContext.Tenant?.Code is { } code ? $"{code}/" : "";

用户传入的目录会先过 ValidateAndNormalizePath,它委托给 SafePathService:

private string ValidateAndNormalizePath(string path)
{
    if (string.IsNullOrWhiteSpace(path)) return string.Empty;

    // 统一走 SafePathService:拒绝 .. / 盘符 / UNC / 非法字符,
    // 并保证后续 Path.Combine 的结果仍然位于调用方指定的根目录内
    try
    {
        return SafePathService.Instance.NormalizeRelative(path);
    }
    catch (UnauthorizedAccessException)
    {
        throw new Exception(_commonLocalizer["路径包含非法字符"]);
    }
}

SafePathService.NormalizeRelative 显式拒绝 ..、盘符(含 :)、UNC(//)和非法字符,而不是依赖 Path.GetFullPath 兜底。

文件名同样会被规范化,并且重名时加随机后缀:

sysFile.SaveFileName = fileReName ? sysFile.FileGuid.ToString() + sysFile.Extension : sysFile.FileName;
if (await _fileRepository.Select.AnyAsync(x => x.FileDirectory == sysFile.FileDirectory && x.FileName == sysFile.FileName))
{
    var end = Random.Shared.Next(1000, 9999);
    sysFile.FileName = AddRandomSuffixToFileName(sysFile.FileName, end);
    if (!fileReName)
        sysFile.SaveFileName = AddRandomSuffixToFileName(sysFile.SaveFileName, end);
}

fileReName 决定"用 GUID 命名"还是"保留原始文件名"。保留原名的场景(比如附件)重名时必须加后缀,否则第二次上传会覆盖第一个文件。

5. 落盘

try
{
    fileDirectory = Path.Combine(env.WebRootPath, fileDirectory).ToPath();
    if (!Directory.Exists(fileDirectory))
    {
        Directory.CreateDirectory(fileDirectory);
    }
    filePath = Path.Combine(env.WebRootPath, filePath).ToPath();
    // 保存到磁盘
    await file.SaveToFileAsync(filePath, MaxSize);

    // 如果是图片则验证并压缩
    sysFile = await ProcessImageAsync(sysFile, filePath, file.GetExtension());

    sysFile = await _fileRepository.InsertAsync(sysFile);
}
catch (Exception ex)
{
    // 磁盘/权限/图片处理/数据库等异常统一转成上传失败,
    // 避免界面的"上传中"提示一直不关闭
    System.Console.WriteLine($"[EasyAdminBlazor] 文件上传异常: {ex}");
    file.Error = string.Format(_commonLocalizer["上传失败:{0}"], ex.Message);
    return null;
}

那句注释很实用:任何异常都要转成 file.Error。如果异常直接抛出,BootstrapBlazor 的上传组件不会收到失败结果,界面上的"上传中"会一直转。

6. 图片处理:ImageSharp

private async Task<SysFile> ProcessImageAsync(SysFile sysFile, string filePath, string? extention)
{
    if (extention != null && extention.IsImage())
    {
        if (!IsImageByImageSharp(filePath))
        {
            File.Delete(filePath);
            throw new InvalidOperationException(_commonLocalizer["图片文件格式无效或已损坏"]);
        }

        bool isWebpFormat = extention.ToLower() == ".webp";

        using (var image = Image.Load(filePath))
        {
            var w = image.Width;
            var h = image.Height;
            var webpPath = isWebpFormat ? filePath : Path.ChangeExtension(filePath, ".webp");

            bool needsResize = w > _maxImageWidth;
            if (needsResize)
            {
                image.Mutate(x => x.Resize(_maxImageWidth, 0, KnownResamplers.Lanczos3));
            }

            if (!isWebpFormat || needsResize)
            {
                var webpEncoder = new WebpEncoder
                {
                    Quality = _webpQuality,
                    Method = WebpEncodingMethod.Level4
                };

                await image.SaveAsWebpAsync(webpPath, webpEncoder);

                if (!isWebpFormat && !_keepOriginalFile)
                {
                    File.Delete(filePath);
                    filePath = webpPath;
                }
                // 之后还会同步 sysFile 的 Size / SizeFormat / SaveFileName / LinkUrl / Extension
            }
        }
    }

    return sysFile;
}

三个行为:

行为 说明
真实解码校验 IsImageByImageSharp 用 ImageSharp 实际加载一次,抓 UnknownImageFormatException 返回 false——头部合法但内容损坏的图片在这里被拦下
超宽缩放 宽度超过 MaxImageWidth 时按 Lanczos3 重采样,高度按比例
转 WebP 非 WebP 或需要缩放时重新编码为 WebP;KeepOriginalFile = false 时删除原文件,并把记录的扩展名、保存名、链接同步更新为 WebP

如果 KeepOriginalFile = true,原图和 WebP 都会保留,数据库记录仍指向原文件——这是"既要省流量又不想丢原图"的折中。


四、数据库记录:SysFile

落库时写入的字段(节选):

var sysFile = new SysFile
{
    Provider = "local",
    BucketName = "",
    FileGuid = FreeUtil.NewMongodbId(),
    FileName = NormalizeFileName(file.OriginFileName ?? file.FileName),
    Extension = extention,
    FileDirectory = fileDirectory,
    Size = fileSize.Size,
    SizeFormat = fileSize.ToString(),
    Md5 = md5
};
字段 用途
Provider / BucketName 预留的云存储扩展位(当前实现是 local)
FileGuid 文件唯一标识,fileReName = true 时作为保存名
FileName / SaveFileName 展示名 / 磁盘名
FileDirectory 相对目录(含租户前缀与日期目录)
Size / SizeFormat 字节数与可读大小
Md5 去重依据(本地上传路径当前留空,远程图片路径用 URL 的 MD5)
IsPublic 是否公开(决定走静态地址还是受控接口)

五、私有文件下载:五步校验

公开文件走静态地址;私有文件必须走 /api/files/{id}:

[ApiController]
[Route("api/files")]
[Authorize]
public class SysFileController : ControllerBase
{
    public async Task<IActionResult> Get(long id)
    {
        // 1) 登录校验:401
        if (!await _adminContext.IsLogin() || _adminContext.User == null)
            return Unauthorized("未登录用户不允许访问文件");

        // 2) 租户校验:文件记录位于当前租户库中,跨租户访问直接拒绝
        if (_adminContext.Tenant != null && !AdminContext.IsValidTenantCode(_adminContext.Tenant.Code))
            return StatusCode(StatusCodes.Status403Forbidden, "无权访问该文件");

        // 3) 记录存在性
        var file = await _fileRepository.GetAsync(id);
        if (file == null) return NotFound();

        // 4) 公开文件仍可直接访问(保持既有行为),私有文件需要显式授权
        if (!file.IsPublic && !await IsAuthorizedAsync(file))
            return StatusCode(StatusCodes.Status403Forbidden, "无权访问该文件");

        // 5) 物理路径校验 + 流式返回
        var opened = await _fileService.OpenPrivateFileAsync(id);
        if (opened == null) return NotFound();

        var (sysFile, stream) = opened.Value;
        var contentType = ResolveContentType(sysFile.Extension);

        // 使用 enableRangeProcessing 支持图片预览与断点续传
        return File(stream, contentType, fileDownloadName: null, enableRangeProcessing: true);
    }
}

授权规则与数据权限保持一致:

private async Task<bool> IsAuthorizedAsync(SysFile file)
{
    await _adminContext.InitRoles();

    if (_adminContext.Roles.Any(r => r.IsAdministrator)) return true;
    if (_adminContext.Roles.Any(r => r.DataPermission == DataPermissionType.AllData)) return true;

    return file.CreatedUserId == _adminContext.User?.Id;
}

OpenPrivateFileAsync 还会再做一次物理路径包含性校验:

// 物理路径必须做根目录包含性校验,避免历史脏数据中的 ".." 越权读取
var root = Path.GetFullPath(_webHostEnvironment.WebRootPath);
var filePath = SafePathService.Instance.Resolve(root, $"{file.FileDirectory}/{file.SaveFileName}");
if (!System.IO.File.Exists(filePath)) return null;

即使数据库里的 FileDirectory 是历史脏数据(含 ..),也不会读到 wwwroot 之外。


六、删除:先校验路径,再动磁盘

public async Task DeleteAsync(long id)
{
    var file = await _fileRepository.GetAsync(id);
    if (file == null) return;

    string filePath;
    try
    {
        filePath = SafePathService.Instance.Resolve(
            env.WebRootPath,
            $"{file.FileDirectory}/{file.SaveFileName}");
    }
    catch (UnauthorizedAccessException)
    {
        // 路径非法时只清理数据库记录,不触碰文件系统
        await _fileRepository.DeleteAsync(file.Id);
        return;
    }

    if (System.IO.File.Exists(filePath))
    {
        System.IO.File.Delete(filePath);
    }

    await _fileRepository.DeleteAsync(file.Id);
}

这是一个很克制的处理:路径非法(可能是被篡改的脏数据)时只删记录、不删文件。宁可留下一个孤儿文件,也不能让一条恶意记录把 wwwroot 之外的文件删掉。


七、配置项速查

{
  "FileSettings": {
    "DirectoryName": "uploads",
    "IncludeExtension": [".jpg", ".jpeg", ".png", ".gif", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".webp"],
    "ExcludeExtension": [".exe", ".dll", ".jar", ".php", ".aspx", ".bat", ".cmd", ".vbs", ".js", ".html", ".htm"],
    "DateTimeDirectory": "yyyy/MM/dd",
    "KeepOriginalFile": false,
    "MaxImageWidth": 800,
    "WebpQuality": 80
  }
}
配置 作用 默认值
DirectoryName 上传根目录 uploads
IncludeExtension 允许的扩展名 图片/文档/表格/文本等
ExcludeExtension 明确拒绝的扩展名 可执行/脚本/HTML
DateTimeDirectory 日期子目录格式 yyyy/MM/dd
KeepOriginalFile 转 WebP 后是否保留原文件 false
MaxImageWidth 图片最大宽度 代码默认 1600,示例配置 800
WebpQuality WebP 质量 80

注意 MaxImageWidth 的代码默认值是 1600,仓库示例配置里是 800,以你项目的配置文件为准。


八、测试锁定的行为

测试文件 覆盖内容
FileSecurityValidatorTests Magic Number 与扩展名匹配、.txt 二进制检测、老 Office 签名
FileUploadSecurityTests 上传路径的扩展名/内容校验
PathTraversalTests ..、盘符、UNC、非法字符被 SafePathService 拒绝
FileDownloadSecurityTests 私有文件下载授权(未登录 401、无权限 403)
SsrfTests 远程图片下载的 SSRF 防护(另见第 11 篇)

九、常见问题

现象 原因 处理
上传后提示"文件内容与扩展名不匹配" 文件头与扩展名不符(或文件损坏) 用真实文件测试;确认该类型在 FileSecurityValidator 的签名表里
上传图片后名字变成 .webp 默认转 WebP 且不保留原文件 需要保留原图就设 KeepOriginalFile = true
图片被缩小了 超过 MaxImageWidth 调整配置(注意代码默认值与示例配置不同)
私有文件 403 非管理员且不是上传人 用上传人账号访问,或给角色配 AllData
路径相关报错 目录含 .. 或非法字符 目录名只用字母数字和短横线
多租户下文件串了 未启用多租户扩展或租户未解析 确认 Tenant 有值,目录会带 {tenantCode}

十、小结

一次上传,服务器侧其实做了七件事:

  1. 看扩展名(白名单 + 黑名单)
  2. 看大小(业务层与 HTTP 层同量级)
  3. 看内容头(Magic Number,未知类型默认拒绝)
  4. 算路径(租户前缀 + 日期目录 + 路径穿越防护)
  5. 定文件名(GUID 或原名 + 重名随机后缀)
  6. 处理图片(真实解码校验 + 缩放 + WebP)
  7. 落库并返回访问地址(公开走静态、私有走受控接口)

理解了这条链路,再遇到"上传失败""图片变形""私有文件打不开"这类问题,就知道该去查哪一段。


如果你正在用 .NET 10 + Blazor 做后台,文件上传是绕不开的基础能力。EasyAdminBlazor 把上传校验、租户隔离、图片处理、私有文件授权做成了一套完整实现,可以直接用,也可以只借鉴其中几层。