using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Threading.Tasks;
namespace TestProject.Utilities
{
/// <summary>
/// 파일 헬퍼
/// </summary>
public static class FileHelper
{
//////////////////////////////////////////////////////////////////////////////////////////////////// Field
////////////////////////////////////////////////////////////////////////////////////////// Static
//////////////////////////////////////////////////////////////////////////////// Private
#region Field
/// <summary>
/// 허용 문자 바이트 배열
/// </summary>
/// <remarks>
/// IsValidFileExtensionAndSignature 메서드의 특정 문자를 확인해야하는 경우
/// _allowedCharacterByteArray 필드에 문자를 제공합니다.
/// </remarks>
private static readonly byte[] _allowCharacterByteArray = { };
/// <summary>
/// 파일 시그니처 딕셔너리
/// </summary>
/// <remarks>
/// 더 많은 파일 서명은 파일 서명 데이터베이스 (https://www.filesignatures.net/) 및 추가하려는 파일 형식에 대한 공식 사양을 참조한다.
/// </remarks>
private static readonly Dictionary<string, List<byte[]>> _fileSignatureDictionary = new Dictionary<string, List<byte[]>>
{
{
".gif",
new List<byte[]>
{
new byte[] { 0x47, 0x49, 0x46, 0x38 }
}
},
{
".png",
new List<byte[]>
{
new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }
}
},
{
".jpeg",
new List<byte[]>
{
new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 },
new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 },
new byte[] { 0xFF, 0xD8, 0xFF, 0xE3 }
}
},
{
".jpg",
new List<byte[]>
{
new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 },
new byte[] { 0xFF, 0xD8, 0xFF, 0xE1 },
new byte[] { 0xFF, 0xD8, 0xFF, 0xE8 }
}
},
{
".zip",
new List<byte[]>
{
new byte[] { 0x50, 0x4B, 0x03, 0x04 },
new byte[] { 0x50, 0x4B, 0x4C, 0x49, 0x54, 0x45 },
new byte[] { 0x50, 0x4B, 0x53, 0x70, 0x58 },
new byte[] { 0x50, 0x4B, 0x05, 0x06 },
new byte[] { 0x50, 0x4B, 0x07, 0x08 },
new byte[] { 0x57, 0x69, 0x6E, 0x5A, 0x69, 0x70 }
}
}
};
#endregion
//////////////////////////////////////////////////////////////////////////////////////////////////// Method
////////////////////////////////////////////////////////////////////////////////////////// Static
//////////////////////////////////////////////////////////////////////////////// Public
// 경고!
// 다음 파일 처리 방법에서는 파일의 내용이 검사되지 않는다.
// 대부분의 프로덕션 시나리오에서 바이러스 백신/멀웨어 방지 스캐너 API는
// 사용자나 다른 시스템에서 파일을 사용할 수 있도록 하기 전에 파일에 사용된다.
// 자세한 정보는 이 샘플 앱과 함께 제공되는 주제를 참조한다.
#region 파일에서 처리하기 - ProcessFormFile<T>(formFile, modelStateDictionary, sourceFileExtensionArray, fileSizeLimit)
/// <summary>
/// 파일에서 처리하기
/// </summary>
/// <typeparam name="T">타입</typeparam>
/// <param name="formFile">폼 파일</param>
/// <param name="modelStateDictionary">모델 상태 딕셔너리</param>
/// <param name="sourceFileExtensionArray">소스 파일 확장자 배열</param>
/// <param name="fileSizeLimit">파일 크기 제한</param>
/// <returns>바이트 배열 태스크</returns>
public static async Task<byte[]> ProcessFormFile<T>
(
IFormFile formFile,
ModelStateDictionary modelStateDictionary,
string[] sourceFileExtensionArray,
long fileSizeLimit
)
{
string fieldDisplayName = string.Empty;
// 리플렉션을 사용하여 이 IFormFile과 연결된 모델 속성의 표시 이름을 가져온다.
// 표시 이름을 찾을 수 없는 경우 오류 메시지에 표시 이름이 표시되지 않는다.
MemberInfo propertyMemberInfo = typeof(T).GetProperty
(
formFile.Name.Substring
(
formFile.Name.IndexOf(".", StringComparison.Ordinal) + 1
)
);
if(propertyMemberInfo != null)
{
if(propertyMemberInfo.GetCustomAttribute(typeof(DisplayAttribute)) is DisplayAttribute displayAttribute)
{
fieldDisplayName = $"{displayAttribute.Name} ";
}
}
// 클라이언트가 보낸 파일 이름을 신뢰하지 않는다.
// 파일 이름을 표시하려면 값을 HTML로 인코딩한다.
string trustedFileNameForDisplay = WebUtility.HtmlEncode(formFile.FileName);
// 파일 길이를 확인한다.
// 이 검사는 내용으로 BOM만 있는 파일을 포착하지 않는다.
if(formFile.Length == 0)
{
modelStateDictionary.AddModelError
(
formFile.Name,
$"{fieldDisplayName}({trustedFileNameForDisplay}) is empty."
);
return new byte[0];
}
if(formFile.Length > fileSizeLimit)
{
long fileSizeLimitMB = fileSizeLimit / 1048576;
modelStateDictionary.AddModelError
(
formFile.Name,
$"{fieldDisplayName}({trustedFileNameForDisplay}) exceeds {fileSizeLimitMB:N1} MB."
);
return new byte[0];
}
try
{
using(MemoryStream memoryStream = new MemoryStream())
{
await formFile.CopyToAsync(memoryStream);
// 파일의 유일한 내용이 BOM이었고
// BOM을 제거한 후 내용이 실제로 비어있는 경우 내용 길이를 확인한다.
if(memoryStream.Length == 0)
{
modelStateDictionary.AddModelError
(
formFile.Name,
$"{fieldDisplayName}({trustedFileNameForDisplay}) is empty."
);
}
if(!IsValidFileExtensionAndSignature(formFile.FileName, memoryStream, sourceFileExtensionArray))
{
modelStateDictionary.AddModelError
(
formFile.Name,
$"{fieldDisplayName}({trustedFileNameForDisplay}) file type isn't permitted or " +
"the file's signature doesn't match the file's extension."
);
}
else
{
return memoryStream.ToArray();
}
}
}
catch(Exception exception)
{
modelStateDictionary.AddModelError
(
formFile.Name,
$"{fieldDisplayName}({trustedFileNameForDisplay}) upload failed. " +
"Please contact the Help Desk for support. Error : {exception.HResult}"
);
}
return new byte[0];
}
#endregion
#region 스트림 파일 처리하기 - ProcessStreamedFile(multipartSection, contentDispositionHeaderValue, modelStateDictionary, sourceFileExtensionArray, fileSizeLimit)
/// <summary>
/// 스트림 파일 처리하기
/// </summary>
/// <param name="multipartSection">멀티 파트 섹션</param>
/// <param name="contentDispositionHeaderValue">컨텐트 배치 헤더 값</param>
/// <param name="modelStateDictionary">모델 상태 딕셔너리</param>
/// <param name="sourceFileExtensionArray">소스 파일 확장자 배열</param>
/// <param name="fileSizeLimit">파일 크기 제한</param>
/// <returns>바이트 배열 태스크</returns>
public static async Task<byte[]> ProcessStreamedFile
(
MultipartSection multipartSection,
ContentDispositionHeaderValue contentDispositionHeaderValue,
ModelStateDictionary modelStateDictionary,
string[] sourceFileExtensionArray,
long fileSizeLimit
)
{
try
{
using(MemoryStream memoryStream = new MemoryStream())
{
await multipartSection.Body.CopyToAsync(memoryStream);
// 파일이 비어 있거나 크기 제한을 초과하는지 확인한다.
if(memoryStream.Length == 0)
{
modelStateDictionary.AddModelError("File", "The file is empty.");
}
else if(memoryStream.Length > fileSizeLimit)
{
long fileSizeLimitMB = fileSizeLimit / 1048576;
modelStateDictionary.AddModelError("File", $"The file exceeds {fileSizeLimitMB:N1} MB.");
}
else if
(
!IsValidFileExtensionAndSignature
(
contentDispositionHeaderValue.FileName.Value,
memoryStream,
sourceFileExtensionArray
)
)
{
modelStateDictionary.AddModelError
(
"File",
"The file type isn't permitted or the file's signature doesn't match the file's extension."
);
}
else
{
return memoryStream.ToArray();
}
}
}
catch(Exception exception)
{
modelStateDictionary.AddModelError
(
"File",
$"The upload failed. Please contact the Help Desk for support. Error: {exception.HResult}"
);
}
return new byte[0];
}
#endregion
//////////////////////////////////////////////////////////////////////////////// Private
#region 파일 확장자/시그니처 유효 여부 구하기 - IsValidFileExtensionAndSignature(fileName, stream, sourceFileExtensionArray)
/// <summary>
/// 파일 확장자/시그니처 유효 여부 구하기
/// </summary>
/// <param name="fileName">파일명</param>
/// <param name="stream">스트림</param>
/// <param name="sourceFileExtensionArray">소스 파일 확장자 배열</param>
/// <returns>파일 확장자/시그니처 유효 여부</returns>
private static bool IsValidFileExtensionAndSignature(string fileName, Stream stream, string[] sourceFileExtensionArray)
{
if(string.IsNullOrEmpty(fileName) || stream == null || stream.Length == 0)
{
return false;
}
string fileExtension = Path.GetExtension(fileName).ToLowerInvariant();
if(string.IsNullOrEmpty(fileExtension) || !sourceFileExtensionArray.Contains(fileExtension))
{
return false;
}
stream.Position = 0;
using(BinaryReader reader = new BinaryReader(stream))
{
if(fileExtension.Equals(".txt") || fileExtension.Equals(".csv") || fileExtension.Equals(".prn"))
{
if(_allowCharacterByteArray.Length == 0)
{
// 문자를 ASCII 인코딩으로 제한한다.
for(long i = 0L; i < stream.Length; i++)
{
if(reader.ReadByte() > sbyte.MaxValue)
{
return false;
}
}
}
else
{
// 문자를 ASCII 인코딩 및 _allowedCharacterByteArray 배열의 값으로 제한합니다.
for(long i = 0L; i < stream.Length; i++)
{
byte byteValue = reader.ReadByte();
if(byteValue > sbyte.MaxValue || !_allowCharacterByteArray.Contains(byteValue))
{
return false;
}
}
}
return true;
}
// _fileSignatureDictionary에 서명이 제공되지 않은 파일을 허용해야 하는 경우
// 다음 코드 블록의 주석 처리를 제거한다.
// 시스템에서 허용하려는 모든 파일 형식에 대해 가능한 경우
// 파일에 대한 파일 서명을 추가하고 파일 서명 검사를 수행하는 것이 좋다.
//if(!_fileSignatureDictionary.ContainsKey(fileExtension))
//{
// return true;
//}
// 파일 서명 확인
// --------------
// _fileSignatureDictionary에 제공된 파일 서명을 사용하여
// 다음 코드는 입력 콘텐츠의 파일 서명을 테스트한다.
List<byte[]> signatureList = _fileSignatureDictionary[fileExtension];
byte[] headerByteArray = reader.ReadBytes(signatureList.Max(m => m.Length));
return signatureList.Any(signature => headerByteArray.Take(signature.Length).SequenceEqual(signature));
}
}
#endregion
}
}