Завантаження файлів — одна з найпоширеніших задач у веб-застосунках. Avatar користувача, PDF-звіти, зображення продуктів, документи. Кожна з цих задач має свою специфіку: різні обмеження розміру, різні вимоги до зберігання (публічний доступ чи захищений), різні стратегії обробки.
ASP.NET Core MVC надає простий та елегантний інтерфейс для роботи з файлами — IFormFile. Але за цією простотою ховається багато нюансів: як правильно валідувати файли, де їх зберігати, як захистити чутливі файли від несанкціонованого доступу.
IFormFile — це інтерфейс що представляє завантажений файл у HTTP-запиті. Він є частиною Microsoft.AspNetCore.Http і доступний у MVC через Model Binding:
public interface IFormFile
{
string ContentType { get; } // MIME-тип: "image/jpeg", "application/pdf"
string ContentDisposition { get; } // Content-Disposition заголовок
IHeaderDictionary Headers { get; } // Headers частини multipart
long Length { get; } // Розмір у байтах
string Name { get; } // Ім'я поля форми (input name="...")
string FileName { get; } // Оригінальне ім'я файлу від клієнта
Stream OpenReadStream(); // Відкрити потік за один файл
void CopyTo(Stream target); // Синхронне копіювання
Task CopyToAsync(Stream target, CancellationToken ct = default); // Async
}
IFormFileCollection — колекція файлів для <input type="file" multiple>.
Ключова вимога форми — enctype="multipart/form-data". Без цього файл не передається:
@model UserProfileDto
<form asp-action="UpdateAvatar" method="post" enctype="multipart/form-data">
@Html.AntiForgeryToken()
<div class="mb-3">
<label asp-for="Avatar" class="form-label">Фото профілю</label>
<input asp-for="Avatar"
type="file"
class="form-control"
accept="image/jpeg,image/png,image/webp"> @* підказка браузеру *@
<span asp-validation-for="Avatar" class="text-danger"></span>
<div class="form-text">Формати: JPEG, PNG, WebP. Максимум 5 МБ.</div>
</div>
<button type="submit" class="btn btn-primary">Зберегти</button>
</form>
@* Множинне завантаження *@
<form asp-action="UploadGallery" method="post" enctype="multipart/form-data">
@Html.AntiForgeryToken()
<input type="file" name="images" multiple accept="image/*" class="form-control">
<button type="submit" class="btn btn-primary mt-2">Завантажити</button>
</form>
Ніколи не довіряйте клієнту. ContentType та FileName — це рядки що надходять від браузера і можуть бути підроблені. Правильна валідація завжди перевіряє фактичний вміст файлу (magic bytes).
using System.ComponentModel.DataAnnotations;
using BlogApp.Attributes;
namespace BlogApp.Models;
public class UserProfileDto
{
[Required]
[StringLength(100)]
public string DisplayName { get; set; } = "";
// Власний атрибут для валідації файлу
[AllowedFileExtensions(".jpg", ".jpeg", ".png", ".webp")]
[MaxFileSize(5 * 1024 * 1024)] // 5MB
public IFormFile? Avatar { get; set; }
}
using System.ComponentModel.DataAnnotations;
namespace BlogApp.Attributes;
[AttributeUsage(AttributeTargets.Property)]
public class AllowedFileExtensionsAttribute : ValidationAttribute
{
private readonly string[] _extensions;
public AllowedFileExtensionsAttribute(params string[] extensions)
{
_extensions = extensions.Select(e => e.ToLowerInvariant()).ToArray();
}
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
{
if (value is not IFormFile file) return ValidationResult.Success;
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!_extensions.Contains(ext))
{
return new ValidationResult(
$"Дозволені формати: {string.Join(", ", _extensions)}",
[ctx.MemberName!]
);
}
return ValidationResult.Success;
}
}
using System.ComponentModel.DataAnnotations;
namespace BlogApp.Attributes;
[AttributeUsage(AttributeTargets.Property)]
public class MaxFileSizeAttribute : ValidationAttribute
{
private readonly long _maxBytes;
public MaxFileSizeAttribute(long maxBytes) => _maxBytes = maxBytes;
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
{
if (value is not IFormFile file) return ValidationResult.Success;
if (file.Length > _maxBytes)
{
var mb = _maxBytes / (1024.0 * 1024.0);
return new ValidationResult(
$"Розмір файлу не може перевищувати {mb:F0} МБ",
[ctx.MemberName!]
);
}
return ValidationResult.Success;
}
}
Розширення та ContentType можна підробити. Надійна перевірка — аналіз перших байтів файлу (magic bytes):
namespace BlogApp.Services;
public static class FileValidator
{
// Magic bytes для поширених форматів зображень
private static readonly Dictionary<string, byte[][]> ImageSignatures = new()
{
["image/jpeg"] = [[0xFF, 0xD8, 0xFF]],
["image/png"] = [[0x89, 0x50, 0x4E, 0x47]],
["image/gif"] = [[0x47, 0x49, 0x46, 0x38]],
["image/webp"] = [[0x52, 0x49, 0x46, 0x46]], // "RIFF"
["application/pdf"] = [[0x25, 0x50, 0x44, 0x46]], // "%PDF"
};
public static async Task<bool> IsValidImageAsync(IFormFile file)
{
if (file.Length == 0) return false;
// Зчитуємо перші 8 байтів
using var stream = file.OpenReadStream();
var header = new byte[8];
await stream.ReadAsync(header.AsMemory(0, header.Length));
// Перевіряємо чи відповідають сигнатурі одного з дозволених форматів
foreach (var (_, signatures) in ImageSignatures)
{
foreach (var sig in signatures)
{
if (header.Take(sig.Length).SequenceEqual(sig))
return true;
}
}
return false;
}
}
wwwroot — публічний доступwwwroot — публічна директорія: файли доступні через URL без авторизації. Підходить для аватарів, зображень продуктів, публічних документів.
namespace BlogApp.Services;
public class AvatarService
{
private readonly IWebHostEnvironment _env;
private readonly string _avatarsFolder;
public AvatarService(IWebHostEnvironment env)
{
_env = env;
// Адресна папка: wwwroot/uploads/avatars/
_avatarsFolder = Path.Combine(_env.WebRootPath, "uploads", "avatars");
Directory.CreateDirectory(_avatarsFolder); // створити якщо немає
}
public async Task<string> SaveAvatarAsync(int userId, IFormFile file)
{
// Безпечне ім'я: не довіряємо FileName від клієнта
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var safeFileName = $"avatar-{userId}-{Guid.NewGuid():N}{ext}";
var filePath = Path.Combine(_avatarsFolder, safeFileName);
// Видалити старий аватар якщо є
DeleteOldAvatars(userId);
// Зберегти новий
await using var stream = File.Create(filePath);
await file.CopyToAsync(stream);
// Повертаємо відносний URL для використання у View
return $"/uploads/avatars/{safeFileName}";
}
private void DeleteOldAvatars(int userId)
{
var pattern = $"avatar-{userId}-*";
foreach (var old in Directory.GetFiles(_avatarsFolder, pattern))
File.Delete(old);
}
}
file.FileName напряму як ім'я файлу для збереження. Зловмисник може передати ../../appsettings.json або ../web.config — так звана Path Traversal-атака. Завжди генеруйте власне безпечне ім'я через Guid.NewGuid().wwwroot — захищений доступФайли поза wwwroot недоступні напряму через URL. Для їх видачі потрібен explicit Action-метод що може перевірити права доступу.
namespace BlogApp.Services;
public class DocumentService
{
private readonly IWebHostEnvironment _env;
// Папка НЕ в wwwroot — PrivateStorage/ поряд з wwwroot/
private readonly string _storageRoot;
public DocumentService(IWebHostEnvironment env)
{
_env = env;
// ContentRootPath — корінь проєкту (там де Program.cs, а не wwwroot)
_storageRoot = Path.Combine(_env.ContentRootPath, "PrivateStorage");
Directory.CreateDirectory(_storageRoot);
}
public async Task<string> SaveDocumentAsync(int userId, IFormFile file)
{
var userFolder = Path.Combine(_storageRoot, $"user-{userId}");
Directory.CreateDirectory(userFolder);
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var docId = Guid.NewGuid().ToString("N");
var filePath = Path.Combine(userFolder, $"{docId}{ext}");
await using var stream = File.Create(filePath);
await file.CopyToAsync(stream);
return docId; // зберігаємо лише ID, не абсолютний шлях
}
public string? ResolvePath(int userId, string docId, string ext)
{
var filePath = Path.Combine(_storageRoot, $"user-{userId}", $"{docId}{ext}");
return File.Exists(filePath) ? filePath : null;
}
}
Для великих файлів (відео, архіви) IFormFile буфер у пам'яті стає проблемою. ASP.NET Core підтримує streaming — читання з HttpRequest.Body напряму без буферизації:
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;
namespace BlogApp.Controllers;
public class UploadController : Controller
{
// Streaming upload для великих файлів (без буферизації у пам'яті)
[HttpPost]
[DisableRequestSizeLimit] // знімаємо ліміт для великих файлів
[RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)]
public async Task<IActionResult> LargeFile()
{
if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
return BadRequest("Потрібен multipart запит");
var boundary = MultipartRequestHelper.GetBoundary(
MediaTypeHeaderValue.Parse(Request.ContentType));
var reader = new MultipartReader(boundary, HttpContext.Request.Body);
MultipartSection? section;
while ((section = await reader.ReadNextSectionAsync()) != null)
{
var hasContentDisposition = ContentDispositionHeaderValue
.TryParse(section.ContentDisposition, out var contentDisposition);
if (!hasContentDisposition || !contentDisposition!.HasFile) continue;
var fileName = $"{Guid.NewGuid():N}.dat";
var filePath = Path.Combine("PrivateStorage", fileName);
// Читаємо потік напряму у файл — без завантаження у RAM
await using var targetStream = File.Create(filePath);
await section.Body.CopyToAsync(targetStream);
}
return Ok(new { Message = "Файл збережено" });
}
}
Для відповіді файлом є кілька типів результатів:
namespace BlogApp.Controllers;
public class UserProfileController : Controller
{
private readonly DocumentService _docs;
private readonly IUserService _users;
public UserProfileController(DocumentService docs, IUserService users)
{
_docs = docs;
_users = users;
}
// Завантажити захищений файл — лише для власника
public async Task<IActionResult> DownloadDocument(string docId)
{
var userId = GetCurrentUserId(); // з ClaimsPrincipal
var filePath = _docs.ResolvePath(userId, docId, ".pdf");
if (filePath is null) return NotFound();
// PhysicalFileResult — для файлів на диску (ефективно: не завантажує у RAM)
return PhysicalFile(
physicalPath: filePath,
contentType: "application/pdf",
fileDownloadName: $"document-{docId}.pdf" // ← пропозиція імені при збереженні
);
}
// Відобразити файл у браузері (не скачати)
public async Task<IActionResult> ViewDocument(string docId)
{
var userId = GetCurrentUserId();
var filePath = _docs.ResolvePath(userId, docId, ".pdf");
if (filePath is null) return NotFound();
// Без fileDownloadName — браузер відкриє inline
return PhysicalFile(filePath, "application/pdf");
}
// FileResult з масиву байтів (наприклад, генерований PDF)
public IActionResult GenerateReport(int reportId)
{
var pdfBytes = GeneratePdfReport(reportId); // ваша логіка генерації
return File(
fileContents: pdfBytes,
contentType: "application/pdf",
fileDownloadName: $"report-{reportId}.pdf"
);
}
// FileStreamResult — для потокового читання (великі файли у wwwroot)
public IActionResult StreamVideo(string videoId)
{
var path = Path.Combine(Directory.GetCurrentDirectory(), "Videos", $"{videoId}.mp4");
if (!System.IO.File.Exists(path)) return NotFound();
var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
return new FileStreamResult(stream, "video/mp4")
{
EnableRangeProcessing = true // підтримка Range запитів для відео
};
}
private int GetCurrentUserId() => 1; // заглушка
private byte[] GeneratePdfReport(int id) => []; // заглушка
}
| Тип | Джерело | Коли використовувати |
|---|---|---|
File(byte[], contentType) | Масив байтів у пам'яті | Малі генеровані файли |
File(Stream, contentType) | Stream | Великі файли, уникнути RAM |
PhysicalFile(path, contentType) | Шлях на диску | Файли з захищеного сховища |
VirtualFile(virtualPath, contentType) | Шлях у wwwroot | Публічні статичні файли через Action |
using BlogApp.Attributes;
using BlogApp.Services;
using Microsoft.AspNetCore.Mvc;
namespace BlogApp.Controllers;
public class UserProfileController : Controller
{
private readonly AvatarService _avatars;
private readonly DocumentService _docs;
public UserProfileController(AvatarService avatars, DocumentService docs)
{
_avatars = avatars;
_docs = docs;
}
[HttpGet]
public IActionResult Edit() => View(new UserProfileDto());
// POST: оновити аватар
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateAvatar(UserProfileDto dto)
{
if (!ModelState.IsValid) return View("Edit", dto);
if (dto.Avatar is null)
{
TempData["Error"] = "Файл не вибрано";
return RedirectToAction(nameof(Edit));
}
// Додаткова перевірка magic bytes (поверх атрибутів)
if (!await FileValidator.IsValidImageAsync(dto.Avatar))
{
ModelState.AddModelError(nameof(dto.Avatar), "Файл не є зображенням");
return View("Edit", dto);
}
var userId = 1; // отримати з ClaimsPrincipal
var avatarUrl = await _avatars.SaveAvatarAsync(userId, dto.Avatar);
// Зберегти URL у профілі користувача...
TempData["Success"] = "Аватар оновлено";
return RedirectToAction(nameof(Edit));
}
// POST: завантажити документ у захищене сховище
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadDocument(IFormFile document)
{
if (document is null || document.Length == 0)
{
TempData["Error"] = "Виберіть файл";
return RedirectToAction(nameof(Edit));
}
// Перевірка розширення
var allowed = new[] { ".pdf", ".doc", ".docx" };
var ext = Path.GetExtension(document.FileName).ToLowerInvariant();
if (!allowed.Contains(ext))
{
TempData["Error"] = "Дозволені: PDF, DOC, DOCX";
return RedirectToAction(nameof(Edit));
}
// Ліміт: 10 МБ
if (document.Length > 10 * 1024 * 1024)
{
TempData["Error"] = "Файл занадто великий (макс. 10 МБ)";
return RedirectToAction(nameof(Edit));
}
var userId = 1;
var docId = await _docs.SaveDocumentAsync(userId, document);
TempData["Success"] = $"Документ збережено (ID: {docId[..8]}...)";
return RedirectToAction(nameof(Edit));
}
// GET: завantажити захищений документ
public IActionResult Download(string docId)
{
var userId = 1;
var filePath = _docs.ResolvePath(userId, docId, ".pdf");
if (filePath is null) return NotFound();
return PhysicalFile(filePath, "application/pdf", $"doc-{docId[..8]}.pdf");
}
}
builder.Services.AddControllersWithViews(options =>
{
// Ліміт розміру форми (за замовчуванням 128MB)
options.Filters.Add(new RequestSizeLimitAttribute(50 * 1024 * 1024)); // 50MB
});
// Ліміт Kestrel
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024; // 50MB
});
// Сервіси
builder.Services.AddScoped<AvatarService>();
builder.Services.AddScoped<DocumentService>();
Завдання 1.1. Реалізуйте ProductController.UploadImage(int productId, IFormFile image) що:
wwwroot/uploads/products/{productId}/main.{ext} (перезаписує старе)RedirectToAction("Details", new {id = productId}) з TempData["Success"]Details.cshtml відображає зображення через <img src="@Model.ImageUrl">Завдання 1.2. Розширте View Edit.cshtml щоб показував поточний аватар (якщо є) поруч з формою завантаження. При відправці форми без нового файлу — аватар залишається незмінним.
Завдання 2.1. Реалізуйте галерею (GalleryController):
UploadImages(int albumId, IFormFileCollection images) — приймає до 10 файлівwwwroot/uploads/albums/{albumId}/{guid}.{ext}PartialView("_GalleryItems", savedUrls) (для HTMX: hx-swap="beforeend")Завдання 2.2. Додайте прогрес-бар завантаження через HTMX та hx-on::xhr:progress:
<form hx-post="/gallery/upload"
hx-encoding="multipart/form-data"
hx-on::xhr:progress="updateProgress(event)">
JavaScript функція updateProgress(event) оновлює <progress> елемент через event.detail.loaded / event.detail.total.
Завдання 3.1. Реалізуйте систему захищених downloads з time-limited токенами:
GET /download/request?docId=... — генерується JWT-токен дійсний 10 хвилин з docId та userId у claimsHttpOnly cookie або передається у відповідіGET /download/file?token=... — Controller верифікує токен, перевіряє що userId у токені = поточний користувач, повертає PhysicalFileIFormFile — інтерфейс завантаженого файлу. Ключові властивості: FileName, ContentType, Length, OpenReadStream()enctype="multipart/form-data". Model Binder автоматично прив'язує IFormFile до параметра Actionfile.FileName для збереження — генеруйте ім'я через Guid.NewGuid()PhysicalFile(path, contentType) — ефективна видача файлу з диску (без завантаження у RAM)[DisableRequestSizeLimit] + streaming через MultipartReader — для великих файлівRequestSizeLimitAttribute (MVC) + Kestrel.Limits.MaxRequestBodySizeУ наступній статті — Globalization/Localization: IStringLocalizer<T>, .resx ресурсні файли, RequestLocalizationMiddleware, перемикач мови через URL/cookie.
Практичний проєкт: Каталог товарів з HTMX
Наскрізний проєкт ProductHub від dotnet new до завершеного каталогу товарів. Live-search з debounce, фільтрація по категоріям, infinite scroll, inline edit, модальне вікно створення товару, кошик з OOB swaps, toast-сповіщення. AntiForgery інтеграція. ASP.NET Core MVC + HTMX + Bootstrap 5.
Глобалізація та Локалізація MVC
Globalization та Localization в ASP.NET Core MVC: IStringLocalizer<T>, IHtmlLocalizer<T>, IViewLocalizer, ресурсні файли .resx, RequestLocalizationMiddleware, визначення культури через URL-сегмент, cookie та Accept-Language. Демо: перемикач мови uk-UA/en-US/pl-PL у навбарі з локалізованими помилками валідації.