ASP.NET Core MVC

Завантаження та обробка файлів

File Upload в ASP.NET Core MVC: IFormFile та IFormFileCollection, валідація (MIME-тип, розмір, розширення), збереження у wwwroot та поза webroot, streaming великих файлів, FileResult та PhysicalFileResult, захист доступу до файлів. Демо: UserProfileController — аватар, галерея, захищені файли.

Завантаження та обробка файлів

Завантаження файлів — одна з найпоширеніших задач у веб-застосунках. Avatar користувача, PDF-звіти, зображення продуктів, документи. Кожна з цих задач має свою специфіку: різні обмеження розміру, різні вимоги до зберігання (публічний доступ чи захищений), різні стратегії обробки.

ASP.NET Core MVC надає простий та елегантний інтерфейс для роботи з файлами — IFormFile. Але за цією простотою ховається багато нюансів: як правильно валідувати файли, де їх зберігати, як захистити чутливі файли від несанкціонованого доступу.


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". Без цього файл не передається:

Views/Profile/Edit.cshtml
@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).

ViewModel з валідацією через DataAnnotations

Models/UserProfileDto.cs
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; }
}
Attributes/AllowedFileExtensionsAttribute.cs
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;
    }
}
Attributes/MaxFileSizeAttribute.cs
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;
    }
}

Перевірка Magic Bytes (справжній MIME-тип)

Розширення та ContentType можна підробити. Надійна перевірка — аналіз перших байтів файлу (magic bytes):

Services/FileValidator.cs
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;
    }
}

Збереження файлів

Варіант 1: у wwwroot — публічний доступ

wwwroot — публічна директорія: файли доступні через URL без авторизації. Підходить для аватарів, зображень продуктів, публічних документів.

Services/AvatarService.cs
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().

Варіант 2: поза wwwroot — захищений доступ

Файли поза wwwroot недоступні напряму через URL. Для їх видачі потрібен explicit Action-метод що може перевірити права доступу.

Services/DocumentService.cs
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;
    }
}

Streaming великих файлів

Для великих файлів (відео, архіви) IFormFile буфер у пам'яті стає проблемою. ASP.NET Core підтримує streaming — читання з HttpRequest.Body напряму без буферизації:

Controllers/UploadController.cs
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 = "Файл збережено" });
    }
}

Видача файлів: FileResult та PhysicalFileResult

Для відповіді файлом є кілька типів результатів:

Controllers/UserProfileController.cs
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) => []; // заглушка
}

Порівняння FileResult-типів

ТипДжерелоКоли використовувати
File(byte[], contentType)Масив байтів у пам'ятіМалі генеровані файли
File(Stream, contentType)StreamВеликі файли, уникнути RAM
PhysicalFile(path, contentType)Шлях на дискуФайли з захищеного сховища
VirtualFile(virtualPath, contentType)Шлях у wwwrootПублічні статичні файли через Action

Демо-проєкт: UserProfileController

Controllers/UserProfileController.cs
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");
    }
}

Налаштування лімітів у Program.cs

Program.cs
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.1. Реалізуйте ProductController.UploadImage(int productId, IFormFile image) що:

  • Приймає лише JPEG/PNG/WebP до 2 МБ (через власні атрибути валідації)
  • Зберігає у wwwroot/uploads/products/{productId}/main.{ext} (перезаписує старе)
  • Повертає RedirectToAction("Details", new {id = productId}) з TempData["Success"]
  • У View Details.cshtml відображає зображення через <img src="@Model.ImageUrl">

Завдання 1.2. Розширте View Edit.cshtml щоб показував поточний аватар (якщо є) поруч з формою завантаження. При відправці форми без нового файлу — аватар залишається незмінним.

Рівень 2 — Логіка

Завдання 2.1. Реалізуйте галерею (GalleryController):

  • UploadImages(int albumId, IFormFileCollection images) — приймає до 10 файлів
  • Валідація: кожен файл ≤ 5 МБ, лише JPEG/PNG, перевірка magic bytes
  • Зберігає у wwwroot/uploads/albums/{albumId}/{guid}.{ext}
  • Повертає PartialView("_GalleryItems", savedUrls) (для HTMX: hx-swap="beforeend")
  • View показує завантажені фото одразу без перезавантаження сторінки

Завдання 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 — Архітектура

Завдання 3.1. Реалізуйте систему захищених downloads з time-limited токенами:

  • При запиті завантаження GET /download/request?docId=... — генерується JWT-токен дійсний 10 хвилин з docId та userId у claims
  • Токен зберігається у HttpOnly cookie або передається у відповіді
  • Actual download: GET /download/file?token=... — Controller верифікує токен, перевіряє що userId у токені = поточний користувач, повертає PhysicalFile
  • Реалізуйте middleware що логує кожне завантаження (userId, docId, IP, час)

Резюме

  • IFormFile — інтерфейс завантаженого файлу. Ключові властивості: FileName, ContentType, Length, OpenReadStream()
  • Форма: обов'язково enctype="multipart/form-data". Model Binder автоматично прив'язує IFormFile до параметра Action
  • Валідація: DataAnnotations-атрибути для розміру та розширення, але magic bytes — єдина надійна перевірка типу
  • Безпека: ніколи не використовуйте file.FileName для збереження — генеруйте ім'я через Guid.NewGuid()
  • wwwroot — публічний доступ за URL. Поза wwwroot — захищений доступ лише через Controller
  • PhysicalFile(path, contentType) — ефективна видача файлу з диску (без завантаження у RAM)
  • [DisableRequestSizeLimit] + streaming через MultipartReader — для великих файлів
  • Ліміти: RequestSizeLimitAttribute (MVC) + Kestrel.Limits.MaxRequestBodySize

У наступній статті — Globalization/Localization: IStringLocalizer<T>, .resx ресурсні файли, RequestLocalizationMiddleware, перемикач мови через URL/cookie.