事件总线支持异常处理策略与日志记录
智能大石头 authored at 2025-12-15 19:30:05
4.53 KiB
X
using NewLife.Data;

namespace NewLife.Http;

/// <summary>表单部分</summary>
public class FormFile
{
    #region 属性
    /// <summary>名称</summary>
    public String Name { get; set; } = null!;

    /// <summary>内容描述(Content-Disposition)</summary>
    public String? ContentDisposition { get; set; }

    /// <summary>内容类型</summary>
    public String? ContentType { get; set; }

    /// <summary>文件名(可能包含路径或用户提交的原始名称,需注意安全)</summary>
    public String? FileName { get; set; }

    /// <summary>数据</summary>
    public IPacket? Data { get; set; }

    /// <summary>长度</summary>
    public Int64 Length => Data?.Total ?? 0;

    /// <summary>是否为空(无数据或长度为0)</summary>
    public Boolean IsEmpty => Length == 0;
    #endregion

    #region 读取
    /// <summary>打开数据读取流</summary>
    /// <returns>内存流;如果无数据返回 null</returns>
    public Stream? OpenReadStream() => Data?.GetStream(false);

    /// <summary>复制数据到目标流</summary>
    /// <param name="destination">目标流</param>
    public void WriteTo(Stream destination)
    {
        if (destination == null) throw new ArgumentNullException(nameof(destination));

        Data?.CopyTo(destination);
    }
    #endregion

    #region 文件保存
    /// <summary>获取安全文件名(去除路径,仅文件名部分,可选字符清理)</summary>
    /// <param name="strict">是否替换非法字符为下划线</param>
    /// <returns>安全文件名;如果原始文件名为空则返回 null</returns>
    public String? GetSafeFileName(Boolean strict = true)
    {
        if (FileName.IsNullOrEmpty()) return null;

        // 去除用户可能提交的路径(防目录穿越)
        var name = Path.GetFileName(FileName);
        if (name.IsNullOrEmpty()) return null;

        if (strict)
        {
            foreach (var c in Path.GetInvalidFileNameChars())
            {
                if (name!.IndexOf(c) >= 0) name = name.Replace(c, '_');
            }
        }
        return name;
    }

    /// <summary>保存到文件(保持向后兼容的旧签名)。文件名为空时使用 <see cref="FileName"/>;内部自动截断旧文件。</summary>
    /// <param name="fileName">目标文件名(可为相对/绝对路径)。</param>
    public void SaveToFile(String? fileName = null)
    {
        // 兼容旧逻辑:直接调用内部实现,允许覆盖
        SaveToFile(fileName, overwrite: true, sanitize: false);
    }

    /// <summary>保存到文件(可控是否覆盖与是否清理文件名)。</summary>
    /// <param name="fileName">目标文件名;为空则使用 <see cref="FileName"/></param>
    /// <param name="overwrite">是否允许覆盖已存在文件</param>
    /// <param name="sanitize">是否对文件名做安全处理(仅在使用自身 FileName 且未显式提供 fileName 时生效)</param>
    public void SaveToFile(String? fileName, Boolean overwrite, Boolean sanitize)
    {
        if (fileName.IsNullOrEmpty())
        {
            fileName = sanitize ? GetSafeFileName() : Path.GetFileName(FileName);
        }
        if (fileName.IsNullOrEmpty()) throw new ArgumentNullException(nameof(fileName));
        if (Data == null) throw new ArgumentNullException(nameof(Data));

        InternalSave(fileName!, overwrite);
    }

    /// <summary>保存到指定目录,使用安全文件名。</summary>
    /// <param name="directory">目标目录</param>
    /// <param name="overwrite">是否覆盖已有文件</param>
    public String SaveToDirectory(String directory, Boolean overwrite = false)
    {
        if (directory.IsNullOrEmpty()) throw new ArgumentNullException(nameof(directory));
        var name = GetSafeFileName() ?? throw new ArgumentNullException(nameof(FileName));

        directory.EnsureDirectory(false);
        var full = Path.Combine(directory, name);
        SaveToFile(full, overwrite, sanitize: false);
        return full;
    }

    private void InternalSave(String fileName, Boolean overwrite)
    {
        fileName.EnsureDirectory(true);
        var full = fileName.GetFullPath();
        if (!overwrite && File.Exists(full)) throw new IOException("目标文件已存在且不允许覆盖:" + full);

        // 使用 FileMode.Create 统一(自动截断旧文件),避免旧实现写短文件后尾部残留的风险
        using var fs = File.Open(full, FileMode.Create, FileAccess.Write, FileShare.None);
        Data?.CopyTo(fs);
        fs.Flush();
    }
    #endregion
}