Lekcja 18. Przesyłanie plików (upload) w ASP.NET Core
ŚredniPo co się tego uczymy?
Wiele aplikacji webowych pozwala użytkownikowi przesłać plik — zdjęcie profilowe, załącznik do zgłoszenia, zdjęcie produktu w panelu sprzedawcy. Ta lekcja pokazuje, jak ASP.NET Core odbiera przesłane pliki, jak bezpiecznie zapisać je na serwerze, i jakich reguł bezpieczeństwa PRZESTRZEGAĆ, żeby ta funkcja nie stała się furtką dla ataku.
Teoria
Formularz do przesyłania plików wymaga DODATKOWEGO atrybutu na znaczniku <form>: enctype="multipart/form-data" — bez niego przeglądarka wyśle TYLKO nazwę pliku jako zwykły tekst, a nie jego faktyczną zawartość. Pole pliku to zwykły <input type="file" asp-for="PlikDoWyslania" />.
Interfejs IFormFile. Przesłany plik trafia do PageModel jako właściwość typu IFormFile (oznaczona [BindProperty], dokładnie jak inne pola formularza) — udostępnia m.in. FileName (oryginalna nazwa pliku na komputerze użytkownika — NIGDY nie ufaj jej bezpośrednio, patrz niżej), Length (rozmiar w bajtach), ContentType (typ MIME, np. image/jpeg), oraz metodę CopyToAsync(strumień) zapisującą faktyczną zawartość pliku.
Bezpieczny zapis pliku — zasady, których NIGDY nie łamać:
- Nigdy nie używaj oryginalnej nazwy pliku od użytkownika jako nazwy pliku na serwerze — może zawierać znaki specjalne, ścieżki (
../../../plik— próba wyjścia poza dozwolony folder, atak Path Traversal) albo kolidować z już istniejącym plikiem. Zawsze generuj NOWĄ, bezpieczną nazwę, np.Guid.NewGuid().ToString() + Path.GetExtension(oryginalnaNazwa)(losowy unikalny identyfikator + oryginalne rozszerzenie). - Sprawdzaj rozszerzenie/typ pliku na "białej liście" dozwolonych wartości (np. tylko
.jpg,.png,.pdf) — NIGDY nie pozwalaj przesłać dowolnego typu pliku bez ograniczeń. - Ogranicz maksymalny rozmiar pliku (atrybut
[RequestSizeLimit(...)]na akcji albo globalna konfiguracja) — bez tego ktoś mógłby przesłać gigantyczny plik, przeciążając serwer albo zapełniając dysk. - Zapisuj pliki POZA folderem z kodem aplikacji (typowo w
wwwroot/uploads/dla plików mających być publicznie dostępne, albo w ogóle POZAwwwroot, jeśli plik nie powinien być bezpośrednio dostępny przez URL).
Zapis strumieniowy. using var strumien = new FileStream(pelnaSciezka, FileMode.Create); await plik.CopyToAsync(strumien); — zapisuje zawartość przesłanego pliku na dysk serwera KAWAŁEK PO KAWAŁKU (strumieniowo), zamiast wczytywać cały plik naraz do pamięci — ważne dla dużych plików.
Schemat
Formularz z <input type="file"> (enctype="multipart/form-data")
│
│ POST z zawartością pliku
▼
PageModel.OnPostAsync()
│
▼
IFormFile Plik (właściwość [BindProperty])
│
├── sprawdź Path.GetExtension(Plik.FileName) na białej liście (.jpg, .png)
├── sprawdź Plik.Length <= maksymalny dozwolony rozmiar
│
▼
string bezpiecznaNazwa = Guid.NewGuid() + rozszerzenie
string pelnaSciezka = Path.Combine(wwwrootPath, "uploads", bezpiecznaNazwa)
│
▼
using FileStream + await Plik.CopyToAsync(strumien)
│
▼
Zapis w bazie danych: ścieżka względna do pliku (np. "uploads/abc123.jpg")
Przykład z życia
Formularz dodawania produktu w panelu sprzedawcy pozwala przesłać zdjęcie produktu, a formularz zgłoszenia w systemie helpdesk pozwala załączyć zrzut ekranu z opisem problemu — w obu przypadkach kluczowe jest bezpieczne zapisanie pliku pod NOWĄ, losową nazwą, żeby uniknąć konfliktów i ataków opartych na manipulacji nazwą pliku.
Pages/DodajProdukt.cshtml
<!-- Pages/DodajProdukt.cshtml (rozszerzone o upload zdjęcia) -->
@page
@model DodajProduktModel
<h1>Dodaj produkt ze zdjęciem</h1>
<div asp-validation-summary="All" class="text-danger"></div>
<form method="post" enctype="multipart/form-data">
<div>
<label asp-for="Produkt.Nazwa"></label>
<input asp-for="Produkt.Nazwa" />
</div>
<div>
<label>Zdjęcie produktu (JPG/PNG, max 5 MB)</label>
<input type="file" name="Zdjecie" accept=".jpg,.jpeg,.png" />
</div>
<button type="submit">Zapisz produkt</button>
</form>
Pages/DodajProdukt.cshtml.cs
// Pages/DodajProdukt.cshtml.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MojaStronaApp.Pages;
public class DodajProduktModel : PageModel
{
private readonly IWebHostEnvironment _srodowisko;
public DodajProduktModel(IWebHostEnvironment srodowisko) => _srodowisko = srodowisko;
private static readonly string[] DozwoloneRozszerzenia = { ".jpg", ".jpeg", ".png" };
private const long MaksRozmiarBajty = 5 * 1024 * 1024; // 5 MB
[BindProperty]
public Produkt Produkt { get; set; } = new();
public async Task<IActionResult> OnPostAsync(IFormFile? zdjecie)
{
if (!ModelState.IsValid)
{
return Page();
}
string? sciezkaZdjecia = null;
if (zdjecie is not null && zdjecie.Length > )
{
string rozszerzenie = Path.GetExtension(zdjecie.FileName).ToLowerInvariant();
if (!DozwoloneRozszerzenia.Contains(rozszerzenie))
{
ModelState.AddModelError(string.Empty, "Dozwolone są tylko pliki JPG/PNG.");
return Page();
}
if (zdjecie.Length > MaksRozmiarBajty)
{
ModelState.AddModelError(string.Empty, "Plik jest zbyt duży (maks. 5 MB).");
return Page();
}
string bezpiecznaNazwa = Guid.NewGuid().ToString() + rozszerzenie;
string folderUploads = Path.Combine(_srodowisko.WebRootPath, "uploads");
Directory.CreateDirectory(folderUploads);
string pelnaSciezka = Path.Combine(folderUploads, bezpiecznaNazwa);
using var strumien = new FileStream(pelnaSciezka, FileMode.Create);
await zdjecie.CopyToAsync(strumien);
sciezkaZdjecia = "uploads/" + bezpiecznaNazwa;
}
// Tu docelowo: zapis Produkt.Nazwa + sciezkaZdjecia do bazy danych (EF Core)
return RedirectToPage("/Produkty/Lista");
}
}
Komentarz i wyjaśnienie kodu
IWebHostEnvironment (wstrzyknięty przez Dependency Injection) dostarcza WebRootPath — pełną ścieżkę fizyczną do folderu wwwroot na dysku serwera, niezależnie od tego, gdzie faktycznie leży cała aplikacja. Directory.CreateDirectory(folderUploads) jest bezpieczne do wywołania ZAWSZE — jeśli folder już istnieje, metoda nic nie robi (nie rzuca błędu); jeśli nie istnieje, tworzy go.
Zwróć uwagę na KOLEJNOŚĆ sprawdzeń PRZED faktycznym zapisem pliku: najpierw rozszerzenie (biała lista), potem rozmiar — dopiero gdy OBA sprawdzenia przejdą pomyślnie, generujemy bezpieczną nazwę i zapisujemy plik. Gdyby te sprawdzenia były PO zapisie, złośliwy/zbyt duży plik zdążyłby już trafić na dysk serwera, zanim aplikacja by go odrzuciła.
zdjecie.FileName jest używane WYŁĄCZNIE do odczytania rozszerzenia (Path.GetExtension) — sama ta nazwa NIGDY nie jest używana jako nazwa pliku na serwerze; zamiast niej generujemy Guid.NewGuid(), czyli losowy, praktycznie niepowtarzalny identyfikator.
Ćwiczenie samodzielne
Dodaj formularz z polem <input type="file"> do dowolnej istniejącej strony z tego działu, zaimplementuj bezpieczny zapis pliku jak w przykładzie, i sprawdź w folderze wwwroot/uploads/, że przesłany plik faktycznie się tam pojawił pod nową, losową nazwą.
Zadania do pracy własnej
Dodaj do formularza informację zwrotną dla użytkownika po udanym przesłaniu pliku (np. komunikat "Zdjęcie zostało przesłane" wraz z jego podglądem — znacznik
<img src="/uploads/nazwapliku.jpg" />).Rozbuduj walidację o sprawdzenie
zdjecie.ContentType(np. musi zaczynać się od"image/") jako DODATKOWE zabezpieczenie obok sprawdzenia rozszerzenia — pamiętając, żeContentTypeteż pochodzi od klienta i można go sfałszować, więc to sprawdzenie WSPIERA, ale nie zastępuje sprawdzenia rozszerzenia.Zaimplementuj usuwanie STAREGO zdjęcia przy edycji produktu — gdy użytkownik przesyła NOWE zdjęcie podczas edycji istniejącego produktu, poprzedni plik (którego ścieżka jest zapisana w bazie) powinien zostać usunięty z dysku (
File.Delete, poprzedzoneFile.Exists) PO udanym zapisaniu nowego pliku, żeby nie zaśmiecać serwera nieużywanymi plikami.
Typowe błędy
Zapomnienie o enctype="multipart/form-data" na formularzu — bez tego atrybutu zawartość pliku w ogóle nie zostanie wysłana do serwera, mimo że reszta formularza działa poprawnie.
Użycie oryginalnej nazwy pliku (zdjecie.FileName) jako nazwy zapisu na serwerze — poważne zagrożenie bezpieczeństwa (Path Traversal, nadpisanie istniejących plików, konflikt nazw) — ZAWSZE generuj nową, bezpieczną nazwę.
Brak ograniczenia rozmiaru i typu przesyłanego pliku — bez tych sprawdzeń złośliwy użytkownik mógłby przesłać ogromny plik (przeciążenie serwera/dysku) albo plik wykonywalny podszywający się pod obrazek.
Nawiązanie do egzaminu zawodowego
To rozszerzenie INF.04.7.3 o obsługę plików w kontekście webowym — częsty, praktyczny element wielu prawdziwych aplikacji (profile użytkowników, katalogi produktów, systemy zgłoszeń). Kolejna lekcja przechodzi do Web API — sposobu na udostępnianie danych aplikacjom zewnętrznym (mobilnym, JavaScript) w formacie JSON zamiast stron HTML.