Download و Upload فایل
مدیریت File یکی از کارهای رایج Developer است. هرچند Download/Upload را میتوان بخشی از CRUD دانست، به دلیل نکات متعدد در بخش مستقلی بررسی میشوند.
Downloading Files
برای Download File سه اطلاعات لازم است:
- MIME type فایل.
- محتوای فایل به شکل Byte Array.
- نام فایل.
سپس از Method برابر Results.File استفاده میکنیم. MIME typeها بسیار متنوعاند و Internet Assigned Numbers Authority (IANA) فهرست استاندارد آنها را نگهداری میکند.
برای مثال، ICountryService با Methodای به نام GetFile گسترش داده میشود.
Listing 4-27. ICountryService با GetFile
using Domain.DTOs;
namespace Domain.Services;
public interface ICountryService
{
CountryDto Retrieve(int id);
List<CountryDto> GetAll();
int CreateOrUpdate(CountryDto country);
bool UpdateDescription(int id, string description);
bool Delete(int id);
(
byte[] fileContent,
string mimeType,
string filename
) GetFile();
}
Tuple سه مقدار لازم را برمیگرداند: Byte Array محتوا، MIME type و Filename.
Listing 4-28. GET /countries/download
app.MapGet("/countries/download", (
ICountryService countryService) => {
(
byte[] fileContent,
string mimeType,
string fileName) = countryService.GetFile();
if (fileContent is null || mimeType is null)
return Results.NotFound();
return Results.File(fileContent, mimeType, fileName);
});
Endpoint دادهٔ لازم را از Service میگیرد؛ اگر Content یا MIME type وجود نداشته باشد 404 و در غیر این صورت File Response میدهد.
شکل ۴-۳۱. فراخوانی GET /countries/downloadدر نمونه Filename برابر countries.csv و MIME type برابر text/csv است.
شکل ۴-۳۲. Response دانلود countries.csv در PostmanPostman اگر بتواند File را تفسیر کند، Content را نمایش میدهد؛ مرورگر File را طبق Headerها Download میکند.
شکل ۴-۳۳. Download فایل countries.csv در Browserشکل ۴-۳۴. Headerهای Download شامل Content-Length، Content-Type و Content-Disposition
Uploading Files
Upload کمی پیچیدهتر است، زیرا Integrity و Security فایل باید Validate شود. دو Scenario اصلی داریم:
- یک یا چند File بدون Payload اضافی.
- یک یا چند File همراه Metadata/Payload.
Upload یک یا چند File بدون Payload
برای دریافت یک File در Minimal API از IFormFile استفاده میشود. File ارسالی از multipart/form-data Bind میشود.
Listing 4-29. POST /countries/upload
app.MapPost("/countries/upload", (IFormFile file) =>
{
return Results.Created();
});
FromForm در اینجا ضروری نیست، چون IFormFile ماهیت Source را مشخص میکند.
شکل ۴-۳۵. Upload فایل countries.csv به POST /countries/uploadنام Key در Form Data باید با نام Parameter یعنی file Match کند؛ Matching به Case حساس نیست. Headerهای مهم عبارتاند از Content-Type: multipart/form-data و Content-Length.
شکل ۴-۳۶. Headerهای ارسال File به Serverشکل ۴-۳۷. محتوای IFormFile در Debugبرای چند File از IFormFileCollection استفاده میشود.
Listing 4-30. POST /countries/uploadmany
app.MapPost("/countries/uploadmany", (IFormFileCollection
files) =>
{
return Results.Created();
});
شکل ۴-۳۸. Upload چند File به POST /countries/uploadmanyHeaderها هماناند، ولی مقدارهایی مانند Content-Length با تعداد/حجم Fileها تغییر میکنند.
شکل ۴-۳۹. IFormFileCollection هنگام دریافت دو File
هر عضو Collection خود IFormFile است؛ میتوان روی آن Loop زد و Fileها را به Service فرستاد یا روی Disk ذخیره کرد. نکتهٔ اصلی این بخش نحوهٔ دریافت آنها در Minimal API است.
Upload File همراه Payload
نمیتوان همزمان JSON Body و Form Data مستقل داشت. وقتی Request با multipart/form-data ارسال میشود، Payload نیز باید داخل Form Data قرار گیرد، نه application/json Body. بنابراین Metadata با FromForm Bind میشود.
Listing 4-31. Upload یک/چند File همراه Metadata
app.MapPost("/countries/uploadwithmetadata", (
[FromForm] CountryMetaData countryMetaData, IFormFile file) =>
{
return Results.Created();
}).DisableAntiForgery();
app.MapPost("/countries/uploadmanywithmetadata", (
[FromForm] CountryMetaData
countryMetaData, IFormFileCollection files) =>
{
return Results.Created();
}).DisableAntiForgery();
هر دو Endpoint رفتار مشابه دارند و Metadata و Fileها از یک Request نوع Form دریافت میشوند.
شکل ۴-۴۰. Upload همزمان File و Metadataشکل ۴-۴۱. Bind شدن Metadata همراه IFormFileCollection
Validation فایل Uploadشده
برای Security باید مطمئن شویم File تهدیدی ایجاد نمیکند. حداقل این موارد بررسی شوند:
- Filename فقط شامل Characterهای Alphanumeric و در صورت نیاز Hyphen/Underscore باشد. Slash خطرناک است، چون در Path/Directory معنا دارد.
- Extension با نوع مورد انتظار Match کند؛ برای CSV، پسوند
.csv. - MIME type درست باشد؛ برای CSV،
text/csv. - File Signature یا Magic Bytes: Byteهای ابتدای File نوع واقعی آن را آشکار میکنند. مثلاً Executable معمولاً با Hex Sequence برابر
4D 5A یا 5A 4D آغاز میشود. صرف Rename کردن Extension نباید Validation را دور بزند. - Content خود File نیز در صورت نیاز Validate شود؛ مثال Regex برای String پیشتر دیده شد.
شکل ۴-۴۲. Signature یک File اجراییبرای جلوگیری از Validator عمومی IValidator<IFormFile> که روی همهٔ File Typeها اعمال شود، File و Metadata را در Model مخصوص CountryFileUpload کپسوله میکنیم.
Listing 4-32. CountryFileUpload
public class CountryFileUpload
{
public IFormFile File { get; set; }
public string AuthorName { get; set; }
public string Description { get; set; }
}
File و Metadata عمداً در یک Level قرار گرفتهاند تا Complexity کم بماند.
Listing 4-33. CountryFileUploadValidator
using AspNetCore8MinimalApis.Models;
using FluentValidation;
using FluentValidation.Results;
using System.Text.RegularExpressions;
namespace AspNetCore8MinimalApis.Validators;
public class CountryFileUploadValidator : AbstractValidator<CountryFileUpload>
{
public CountryFileUploadValidator()
{
RuleFor(x => x.File).Must((file, context) =>
{
return file.File.ContentType == "text/csv";
}).WithMessage("ContentType is not valid");
RuleFor(x => x.File).Must((file, context) =>
{
return file.File.FileName.EndsWith(".csv");
}).WithMessage("The file extension is not valid");
RuleFor(x => x.File.FileName).Matches("^[A-Za-z0-9_\\-.]*$")
.WithMessage("The file name is not valid");
RuleFor(x => x).Must((file, context) =>
{
// string representation of hexadecimal signature of an execute file
var exeSignatures = new List<string> {
"4D-5A",
"5A 4D"
};
BinaryReader binary = new BinaryReader(file.File.OpenReadStream());
byte[] bytes = binary.ReadBytes(2); // reading first two bytes
string fileSequenceHex = BitConverter.ToString(bytes);
foreach (var exeSignature in exeSignatures)
if (exeSignature.Equals(
fileSequenceHex,
StringComparison.OrdinalIgnoreCase
))
return false;
return true;
}).WithName("FileContent")
.WithMessage("The file content is not valid");
RuleFor(x => x.AuthorName)
.NotEmpty()
.WithMessage("{PropertyName} is required")
.Custom((authorName, context) =>
{
Regex rg = new Regex("<.*?>"); // Matches HTML tags
if (rg.Matches(authorName).Count > 0)
{
// Raises an error
context.AddFailure(
new ValidationFailure(
"AuthorName",
"The AuthorName parameter has invalid content"));
}
});
RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("{PropertyName} is required")
.Custom((name, context) =>
{
Regex rg = new Regex("<.*?>"); // Matches HTML tags
if (rg.Matches(name).Count > 0)
{
// Raises an error
context.AddFailure(new
ValidationFailure(
"Name",
"The AuthorName parameter has invalid content"));
}
});
}
}
بخش مهمتر Rule مربوط به Magic Bytes است. دو Signature ممنوع EXE به صورت String Hex نگهداری شدهاند. File با OpenReadStream باز، دو Byte اول با BinaryReader خوانده و با BitConverter.ToString به Hex String تبدیل میشود؛ سپس با Signatureهای ممنوع مقایسه میگردد.
شکل ۴-۴۳. شکست Validation برای File اجرایی که Extension آن به CSV تغییر کرده است
Upload از Download پیچیدهتر است. Signatureها یا Magic Byteهای انواع File را میتوان در فهرستهای استاندارد File Signature بررسی کرد.
Streaming Content
Streaming شبیه Download است، اما Client میتواند پیش از کاملشدن Download، Content را مصرف کند؛ مثلاً Video را هنگام دریافت پخش کند. برای Stream کردن Video دو چیز لازم است:
- Video Stream از نوع
Stream. - MIME type مانند
video/mp4.
IStreamingService Methodای برای دریافت Stream و MIME type دارد.
Listing 4-34. IStreamingService
namespace Domain.Services;
public interface IStreamingService
{
Task<(Stream stream, string mimeType)> GetFileStream();
}
Listing 4-35. GET /streaming
app.MapGet("/streaming", async (IStreamingService
streamingService) =>
{
(Stream stream, string mimeType) = await streamingService.
GetFileStream();
return Results.Stream(stream, mimeType,
enableRangeProcessing: true);
});
Results.Stream با enableRangeProcessing: true به Server اجازه میدهد درخواست Range برای قسمت خاصی از Media را مدیریت کند؛ Playerها برای Seek کردن Video از این رفتار استفاده میکنند. در چنین حالتی Response میتواند Partial Content باشد بهجای 200 OK.
Headerهایی مانند Accept-Ranges، Range و Content-Range که در فصل ۱ دیدیم در همین Context کاربرد دارند.
شکل ۴-۴۴. Streaming یک Video با GET /streaming
Handling CORS
Same-Origin Policy (SOP) بهصورت پیشفرض Scriptهای Browser را محدود میکند تا Data را از Origin متفاوت دریافت نکنند. Origin معمولاً Scheme، Host و Port را دربرمیگیرد. هدف این Rule جلوگیری از Requestهای ناامن بین Domainهای غیرمجاز است.
برای مشخصشدن مجاز بودن Cross-Origin Request، Browser ممکن است Preflight Request با Verb برابر OPTIONS بفرستد. Server با Headerهای Access-Control-* مجاز بودن Origin، Method، Header و Credential را اعلام میکند.
- Access-Control-Allow-Origin: Originهای مجاز.
- Access-Control-Allow-Credentials: مجاز بودن Requestهای دارای Credential.
- Access-Control-Allow-Headers: Headerهای مجاز.
- Access-Control-Allow-Methods: HTTP Methodهای مجاز.
- Access-Control-Expose-Headers: Headerهایی که Browser اجازه دارد از Response در اختیار Script قرار دهد.
- Access-Control-Max-Age: زمان Cache شدن Preflight Response به ثانیه.
- Access-Control-Request-Headers: Headerهایی که Client در Request اصلی قصد ارسال دارد.
- Access-Control-Request-Method: Method مورد نظر Client برای Request اصلی.
در ASP.NET Core 8 چهار مورد Allow-Origin، Allow-Methods، Allow-Headers و Allow-Credentials رایجترند.
Listing 4-36. Policy آزاد CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll",
builder =>
{
builder.AllowAnyHeader()
.AllowAnyMethod()
.AllowAnyOrigin();
});
});
نمیتوان همزمان AllowAnyOrigin و AllowCredentials را فعال کرد، زیرا Credential با Origin کاملاً باز ناامن است.
شکل ۴-۴۵. Error ناشی از مجاز کردن Credential و Any Origin همزمانفقط ثبت Policy کافی نیست؛ Middleware باید در Pipeline فعال شود.
Listing 4-37. فعالکردن AllowAll
var app = builder.Build();
app.UseCors("AllowAll");
برای Production باید Policy محدودتر باشد؛ مثلاً Headerها و Credential مجاز باشند، فقط GET/POST/PUT/DELETE اجازه داشته باشند و Origin تنها دو Domain مشخص باشد.
Listing 4-38. CORS محدودشده
options.AddPolicy("Restricted",
builder =>
{
builder.AllowAnyHeader()
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithOrigins("https://mydomain.com",
"https://myotherdomain.com")
.AllowCredentials();
});
برای آزمون، Script جاوااسکریپت از Origin غیرمجاز http://localhost:5150 تلاش میکند API محلی https://localhost:7157 را فراخوانی کند.
Listing 4-39. Request از Origin غیرمجاز
<script>
function getCountries()
{
var xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = function() {
if (xmlHttp.readyState == 4 && xmlHttp.status == 200)
callback(xmlHttp.responseText);
}
xmlHttp.open("GET", "https://localhost:7157/countries", true);
xmlHttp.send(null);
}
getCountries();
</script>
چون Origin مجاز نیست، Browser Request را با CORS Error رد میکند.