Architektura Aplikacji Internetowych / Programowanie .NET

Rok akademicki: 2026/27

Instrukcja laboratoryjna: Architektura ASP.NET Core Razor Pages (.NET 8/9), Entity Framework Core, Minimal API, Identity oraz Lokalizacja

Prowadzący: Tomasz Goluch
Wersja: 1.0


1. Wprowadzenie i paradygmat Page-Centric Architecture

Cel: Zrozumienie założeń architektury zorientowanej na strony (Page-Centric), różnic względem wzorca MVC, cyklu życia klasy PageModel oraz mechanizmów routingu plikowego.

ASP.NET Core Razor Pages to nowoczesny, wspierany natywnie przez platformę .NET 8 LTS / .NET 9 model tworzenia dynamicznych aplikacji webowych generowanych po stronie serwera (Server-Side Rendering). W przeciwieństwie do klasycznego wzorca MVC, który rozprasza pojedynczą funkcjonalność pomiędzy trzy odległe katalogi (Controllers/, Views/, Models/), Razor Pages grupuje widok (.cshtml) oraz powiązaną z nim logikę prezentacji (.cshtml.cs – klasa dziedzicząca po PageModel) w jedną spójną jednostkę organizacyjną (zgodnie z zasadą High Cohesion).

       Żądanie HTTP (np. POST /Rooms/Create)
                  │
                  ▼
         [ Potok Middleware ] (HTTPS, StaticFiles, Routing, Localization, AuthN/AuthZ)
                  │
                  ▼
         [ MapRazorPages() ] ──► Dopasowanie ścieżki URL do pliku w katalogu /Pages
                  │
                  ▼
         [ Klasa PageModel (Create.cshtml.cs) ]
            │  1. Wstrzyknięcie serwisów przez konstruktor (DI: DbContext, ILogger)
            │  2. Automatyczny Model Binding właściwości oznaczonych [BindProperty]
            │  3. Walidacja ModelState i wywołanie metody Handlera: OnPostAsync()
            ▼
         [ Warstwa Danych (EF Core DbContext / Serwisy Domenowe) ]
            │
            ├──► Sukces (PRG): RedirectToPage("./Index") ──► HTTP 302 Found
            │
            └──► Błąd walidacji: return Page()
                                     │
                                     ▼
                        [ Widok Razor (Create.cshtml) ] ──► Renderowanie HTML ──► HTTP 200 OK

🧠 Kiedy wybrać Razor Pages, a kiedy klasyczne MVC lub Minimal API?


2. Środowisko uruchomieniowe, narzędzia CLI i pakiety NuGet

Cel: Inicjalizacja projektu Razor Pages z użyciem .NET CLI, instalacja narzędzi generatora kodu (Scaffolding) oraz uruchomienie systemu z bazą danych Entity Framework Core.

1. Wymagania narzędziowe

2. Tworzenie projektu i instalacja narzędzi z wiersza poleceń (.NET CLI)

# Utworzenie projektu Razor Pages z indywidualnymi kontami użytkowników (ASP.NET Core Identity)
dotnet new webapp -n RazorPagesLabApp -f net8.0 --auth Individual
cd RazorPagesLabApp

# Instalacja globalnych narzędzi EF Core oraz generatora kodu (Scaffolding)
dotnet tool install --global dotnet-ef
dotnet tool install --global dotnet-aspnet-codegenerator

# Instalacja pakietu wspierającego generowanie kodu i Swaggera
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet add package Swashbuckle.AspNetCore

3. Opcje uwierzytelniania przy tworzeniu projektu

Podczas inicjalizacji szablonu aplikacji webowej w Visual Studio lub CLI (--auth) dostępne są cztery główne tryby:

  1. Brak (None): Brak wbudowanej warstwy logowania; identyczny dostęp dla każdego klienta.
  2. Pojedyncze konta (Individual): Lokalna baza danych użytkowników i ról oparta o ASP.NET Core Identity (IdentityDbContext), przechowująca bezpieczne skróty haseł (PBKDF2), tokeny resetowania haseł oraz obsługująca weryfikację dwuetapową (2FA) i potwierdzenia e-mail.
  3. Platforma tożsamości Microsoft (SingleOrg / MultiOrg): Integracja z chmurą Microsoft Entra ID (dawniej Azure Active Directory) oraz Office 365 w oparciu o protokoły OpenID Connect / OAuth 2.0.
  4. Uwierzytelnianie systemu Windows (Windows): Przeznaczone dla sieci intranetowych (Active Directory) z wykorzystaniem protokołów Kerberos / NTLM na serwerze IIS lub Kestrel.

3. Architektura Stron Razor, Handlery Żądań i Wiązanie Modelu

Cel: Opanowanie konfiguracji usług Razor Pages w Program.cs, metod obsługi żądań (Page Handlers), atrybutu [BindProperty] oraz konwencji przekierowań.

1. Konfiguracja w pliku Program.cs

Obsługa stron Razor wymaga zarejestrowania usług w kontenerze DI przed zbudowaniem aplikacji oraz zmapowania punktów końcowych w potoku HTTP:

var builder = WebApplication.CreateBuilder(args);

// 1. Rejestracja usług Razor Pages w kontenerze DI
builder.Services.AddRazorPages();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

// 2. Mapowanie punktów końcowych dla plików .cshtml w katalogu /Pages
app.MapRazorPages();

app.Run();

2. Budowa strony Razor i klasy PageModel

Każda strona składa się z pary plików umieszczonych w katalogu Pages/ (np. Pages/ServerInfo.cshtml oraz Pages/ServerInfo.cshtml.cs):

Plik widoku Pages/ServerInfo.cshtml:

@page "{format?}"
@model RazorPagesLabApp.Pages.ServerInfoModel
@{
    ViewData["Title"] = "Status Serwera";
}

<div class="card shadow-sm p-3">
    <h2>@ViewData["Title"]</h2>
    <p class="lead">@Model.Message</p>
</div>

Plik logiki Pages/ServerInfo.cshtml.cs:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace RazorPagesLabApp.Pages;

public class ServerInfoModel : PageModel
{
    public string Message { get; private set; } = "Inicjalizacja modelu strony C#.";

    // Handler obsługujący żądanie HTTP GET /ServerInfo
    public void OnGet(string? format)
    {
        var currentTime = DateTime.Now.ToString(format ?? "yyyy-MM-dd HH:mm:ss");
        Message = $"Czas na serwerze Kestrel: {currentTime}";
    }
}

3. Metody obsługi żądań (Page Handlers) i Named Handlers

W przeciwieństwie do kontrolerów MVC, w których nazwy metod akcji są dowolne, w Razor Pages metody sterujące zachowaniem strony noszą nazwy powiązane z czasownikiem HTTP i prefiksem On:

4. Typy zwracane przez Handlery

5. Wiązanie danych za pomocą [BindProperty]

W Razor Pages ze względów bezpieczeństwa domyślnie wiązane są wyłącznie parametry przekazywane do argumentów metody handlera. Aby automatycznie powiązać właściwości klasy PageModel z polami formularza HTML:


4. Entity Framework Core, Relacje, Scaffolding i Silnie Typowane Listy (SelectList)

Cel: Automatyczne generowanie stron CRUD w oparciu o kontekst bazy danych EF Core, konfiguracja relacji kluczy obcych oraz prawidłowa implementacja kontrolek <select>.

1. Modelowanie relacji 1:N w Entity Framework Core

Aby generator kodu (Scaffolding) oraz EF Core prawidłowo rozpoznały relację między encjami (np. Product należący do Producer lub Reservation przypisana do Room), w klasie encji podrzędnej definiujemy zarówno właściwość klucza obcego (ProducerId), jak i właściwość nawigacyjną (Producer):

using System.ComponentModel.DataAnnotations;

namespace RazorPagesLabApp.Core.Entities;

public class Producer
{
    public int Id { get; set; }

    [Required, StringLength(100)]
    [Display(Name = "Nazwa producenta")]
    public string Name { get; set; } = string.Empty;

    public ICollection<Product> Products { get; set; } = new List<Product>();
}

public class Product
{
    public int Id { get; set; }

    [Required, StringLength(150)]
    [Display(Name = "Nazwa produktu")]
    public string Name { get; set; } = string.Empty;

    [Display(Name = "Producent")]
    public int ProducerId { get; set; }

    // Właściwość nawigacyjna (nullable, aby uniknąć fałszywych błędów ModelState przy samym zbindowaniu ProducerId!)
    public Producer? Producer { get; set; }
}

⚠️ Pułapka Nullable Reference Types (.NET 8/9) w encjach EF Core!
Od wersji .NET 6 projekty mają domyślnie włączoną flagę <Nullable>enable</Nullable>. Jeśli właściwość nawigacyjna public Producer Producer { get; set; } nie zostanie oznaczona znakiem zapytania (Producer?), walidator ASP.NET Core uzna ją za pole wymagane ([Required]). Ponieważ formularz przesyła z listy rozwijanej wyłącznie klucz obcy ProducerId (np. 5), właściwość obiektowa Producer pozostaje null, a ModelState.IsValid zawsze zwróci false! Oznaczaj właściwości nawigacyjne jako opcjonalne (?) lub stosuj dedykowane modele widoku / [ValidateNever].

2. Generowanie stron CRUD (Scaffolding) i rejestracja DbContext

W środowisku Visual Studio klikamy prawym przyciskiem myszy na utworzonym podfolderze (np. Pages/Products) i wybieramy: Add → New Scaffolded Item → Razor Pages using Entity Framework (CRUD). Z wiersza poleceń operację tę wykonujemy następująco:

dotnet aspnet-codegenerator razorpage -m Product -dc ApplicationDbContext -udl -outDir Pages/Products --referenceScriptLibraries

W pliku Program.cs rejestrowany jest kontekst bazy danych w kontenerze DI:

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection")
        ?? throw new InvalidOperationException("Brak ciągu połączenia 'DefaultConnection'.")));

(Podczas testów można tymczasowo przełączyć się na bazę w pamięci RAM, wywołując options.UseInMemoryDatabase("TestDb"), natomiast przy przejściu na fizyczną bazę SQL wymagane jest wygenerowanie i zaaplikowanie migracji: dotnet ef migrations add InitialCreate oraz dotnet ef database update).

3. Dyrektywy i Helpery w wygenerowanych stronach

4. Implementacja listy rozwijanej (<select> / ComboBox) w sposób silnie typowany

Domyślny kod ze scaffoldingu często wykorzystuje niezalecany słownik ViewData["ProducerId"] lub ViewBag. Dobrą praktyką inżynierską jest zdefiniowanie silnie typowanej właściwości SelectList bezpośrednio w klasie PageModel:

Klasa Pages/Products/Create.cshtml.cs:

public class CreateModel : PageModel
{
    private readonly ApplicationDbContext _context;

    public CreateModel(ApplicationDbContext context)
    {
        _context = context;
    }

    [BindProperty]
    public Product Product { get; set; } = default!;

    // Silnie typowana kolekcja opcji dla kontrolki <select>
    public SelectList ProducerOptions { get; set; } = default!;

    public async Task<IActionResult> OnGetAsync()
    {
        await PopulateProducersDropDownListAsync();
        return Page();
    }

    public async Task<IActionResult> OnPostAsync()
    {
        if (!ModelState.IsValid)
        {
            // KRYTYCZNE: Przy błędzie walidacji protokół HTTP jest bezstanowy –
            // musimy ponownie załadować opcje listy, inaczej <select> będzie pusty!
            await PopulateProducersDropDownListAsync(Product.ProducerId);
            return Page();
        }

        _context.Products.Add(Product);
        await _context.SaveChangesAsync();

        return RedirectToPage("./Index");
    }

    private async Task PopulateProducersDropDownListAsync(object? selectedProducer = null)
    {
        var producersQuery = _context.Producers
            .AsNoTracking()
            .OrderBy(p => p.Name);

        ProducerOptions = new SelectList(
            await producersQuery.ToListAsync(),
            dataValueField: nameof(Producer.Id),
            dataTextField: nameof(Producer.Name),
            selectedValue: selectedProducer);
    }
}

Widok Pages/Products/Create.cshtml:

@page
@model RazorPagesLabApp.Pages.Products.CreateModel

<form method="post">
    <div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>

    <div class="mb-3">
        <label asp-for="Product.Name" class="form-label"></label>
        <input asp-for="Product.Name" class="form-control" />
        <span asp-validation-for="Product.Name" class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Product.ProducerId" class="form-label"></label>
        <select asp-for="Product.ProducerId"
                asp-items="Model.ProducerOptions"
                class="form-select">
            <option value="">-- Wybierz producenta z listy --</option>
        </select>
        <span asp-validation-for="Product.ProducerId" class="text-danger"></span>
    </div>

    <button type="submit" class="btn btn-primary">Zapisz produkt</button>
</form>

5. Zestawienie najważniejszych Tag Helperów w Razor Pages

Tag Helper Przykładowy element HTML Opis działania
asp-page <a asp-page="./Details">...</a> Generuje atrybut href wskazujący na stronę Razor (ścieżka względna ./ lub bezwzględna /).
asp-page-handler <button asp-page-handler="Cancel"> Kieruje żądanie formularza lub linku do konkretnej metody OnPostCancel / OnGetCancel.
asp-route-{key} <a asp-page="./Edit" asp-route-id="@item.Id"> Przekazuje parametr trasy lub ciągu zapytania (np. /Products/Edit?id=5 lub /Products/Edit/5).
asp-for <input asp-for="Product.Name" /> Wiąże pole z właściwością modelu: generuje id, name, type, value oraz atrybuty walidacji data-val-*.
asp-items <select asp-for="..." asp-items="Model.List"> Generuje listę znaczników <option value="...">Tekst</option> na podstawie kolekcji IEnumerable<SelectListItem>.
asp-validation-for <span asp-validation-for="Product.Name"></span> Stosowany ze znacznikiem <span> – wyświetla błąd walidacji pojedynczego pola modelu.
asp-validation-summary <div asp-validation-summary="ModelOnly"></div> Stosowany ze znacznikiem <div> – wyświetla podsumowanie błędów (All, ModelOnly lub None).

5. Internacjonalizacja i Lokalizacja (I18n / L10n) w Razor Pages

Cel: Wdrożenie pełnego wsparcia wielojęzycznego (pl-PL, en-US) z wykorzystaniem plików zasobów .resx, zestawów satelickich (Satellite Assemblies) oraz lokalizacji adnotacji danych i formatów dat.

1. Architektura zasobów i Satellite Assemblies

Wszystkie ciągi tekstowe zależne od języka i kultury są podczas kompilacji przekształcane z plików XML (*.resx) do binarnych plików zasobów (*.resources) w katalogu obj/, a następnie osadzane w tzw. zestawach satelickich (Satellite Assemblies) umieszczanych w podkatalogach wynikowych bin/<Configuration>/net8.0/<kod-kultury>/ (np. bin/Debug/net8.0/pl-PL/RazorPagesLabApp.resources.dll).

2. Konwencja nazewnictwa plików .resx (Kluczowe rozróżnienie!)

W ASP.NET Core istnieją dwa sposoby wstrzykiwania tłumaczeń, różniące się oczekiwaną nazwą pliku .resx w katalogu Resources/:

  1. Lokalizacja oparta na typie klasy (IStringLocalizer<T> lub zasoby współdzielone):

    • Ścieżka pliku .resx odpowiada przestrzeni nazw i nazwie klasy C# (bez rozszerzenia .cshtml!).
    • Dla klasy modelu Pages/Products/IndexModel (namespace RazorPagesLabApp.Pages.Products):
      • Resources/Pages/Products/IndexModel.pl-PL.resx
      • Resources/Pages/Products/IndexModel.en-US.resx
    • Zasoby współdzielone (Shared Resources): Aby uniknąć duplikowania słów takich jak „Zapisz”, „Anuluj”, „Usuń”, tworzymy pustą klasę-znacznik (Marker Class) w głównym katalogu projektu lub w Resources/SharedResource.cs:
      namespace RazorPagesLabApp;
      public class SharedResource { }
      oraz odpowiadające jej pliki Resources/SharedResource.pl-PL.resx i Resources/SharedResource.en-US.resx.
  2. Lokalizacja oparta na ścieżce widoku (IViewLocalizer):

    • Ścieżka pliku .resx odwzorowuje dokładną ścieżkę pliku szablonu .cshtml:
      • Widok: Pages/Products/Index.cshtml
      • Zasób (PL): Resources/Pages/Products/Index.cshtml.pl-PL.resx
      • Zasób (EN): Resources/Pages/Products/Index.cshtml.en-US.resx

💡 Wskazówka: Podczas korzystania z IStringLocalizer<T> lub IViewLocalizer w .NET 8/9 nie trzeba włączać generowania klas C# w edytorze zasobów (Access Modifier: No code generation jest wystarczające), chyba że chcemy odwoływać się do właściwości statycznych w atrybutach [Display(Name = "RoomName", ResourceType = typeof(...))]. Znacznie wygodniejszym rozwiązaniem dla adnotacji jest automatyczna konfiguracja DataAnnotationsLocalization ze wspólną klasą SharedResource.

3. Rejestracja i konfiguracja potoku lokalizacji w Program.cs

using System.Globalization;
using Microsoft.AspNetCore.Localization;

// 1. Rejestracja usług lokalizacji ze wskazaniem katalogu Resources
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

builder.Services.AddRazorPages()
    .AddViewLocalization()
    .AddDataAnnotationsLocalization(options =>
    {
        // Wszystkie atrybuty [Required], [Display], [Range] będą tłumaczone przez jeden plik SharedResource!
        options.DataAnnotationLocalizerProvider = (type, factory) =>
            factory.Create(typeof(SharedResource));
    });

var app = builder.Build();

// 2. Definicja wspieranych kultur (formatowanie dat/liczb oraz interfejs UI)
var supportedCultures = new[]
{
    new CultureInfo("pl-PL"),
    new CultureInfo("en-US")
};

var localizationOptions = new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture("pl-PL"),
    SupportedCultures = supportedCultures,
    SupportedUICultures = supportedCultures
};

// Domyślnie RequestLocalizationOptions zawiera już wbudowanych dostawców w kolejności:
// 1. QueryStringRequestCultureProvider (?culture=en-US&ui-culture=en-US)
// 2. CookieRequestCultureProvider (.AspNetCore.Culture)
// 3. AcceptLanguageHeaderRequestCultureProvider (nagłówek przeglądarki)

// WAŻNE: UseRequestLocalization musi znaleźć się PRZED MapRazorPages()!
app.UseRequestLocalization(localizationOptions);

4. Wykorzystanie w widokach i klasach PageModel

Dodajemy globalne importy w Pages/_ViewImports.cshtml:

@using Microsoft.Extensions.Localization
@using Microsoft.AspNetCore.Mvc.Localization
@inject IStringLocalizer<SharedResource> SharedLocalizer

W widoku .cshtml lub klasie PageModel:

public class IndexModel : PageModel
{
    private readonly IStringLocalizer<SharedResource> _localizer;

    public IndexModel(IStringLocalizer<SharedResource> localizer)
    {
        _localizer = localizer;
    }

    public void OnGet()
    {
        ViewData["Title"] = _localizer["AvailableSlotsHeader"];
    }
}

6. Minimal API, Dokumentacja OpenAPI (Swagger) i Klient HTTP

Cel: Udostępnienie lekkich punktów końcowych REST API obok stron Razor Pages, separacja modelu domenowego poprzez obiekty DTO oraz komunikacja z poziomu aplikacji konsolowej przy użyciu HttpClient i System.Text.Json.

1. Dlaczego obiekty DTO (Data Transfer Objects) są niezbędne?

Bezpośrednie zwracanie lub przyjmowanie encji bazodanowych EF Core przez API prowadzi do:

Dlatego we współdzielonej bibliotece klas (.Core) definiujemy lekkie rekordy lub klasy DTO:

namespace ProjectDefense.Core.DTOs;

public record RoomDto(int Id, string Name, string RoomNumber, int Capacity);

public record AvailableSlotDto(
    int SlotId,
    string RoomName,
    string LecturerName,
    DateTime StartTime,
    DateTime EndTime);

public record BookSlotRequestDto(int SlotId, string StudentEmail);

2. Konfiguracja Minimal API oraz Swagger UI w Program.cs

// 1. Rejestracja generatora OpenAPI / Swagger i serwisów biznesowych
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<IReservationService, ReservationService>();

var app = builder.Build();

// 2. Aktywacja oprogramowania pośredniczącego Swagger
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(); // Dostępne pod adresem: https://localhost:<port>/swagger
}

// 3. Grupowanie i mapowanie punktów końcowych Minimal API z użyciem TypedResults
var api = app.MapGroup("/api").AllowAnonymous();

api.MapGet("/rooms", async (IReservationService service) =>
{
    var rooms = await service.GetAllRoomsAsync();
    return TypedResults.Ok(rooms);
})
.WithName("GetRooms")
.WithOpenApi();

api.MapGet("/slots/available", async (int? roomId, IReservationService service) =>
{
    var slots = await service.GetAvailableSlotsAsync(roomId);
    return TypedResults.Ok(slots);
})
.WithName("GetAvailableSlots")
.WithOpenApi();

api.MapPost("/slots/{id:int}/book", async (int id, BookSlotRequestDto request, IReservationService service) =>
{
    if (id != request.SlotId)
    {
        return Results.BadRequest(new { error = "Niezgodność identyfikatora slotu w adresie URL i treści żądania." });
    }

    var result = await service.BookSlotAsync(id, request.StudentEmail);
    if (!result.IsSuccess)
    {
        return Results.Conflict(new { error = result.ErrorMessage });
    }

    return Results.Ok(new { message = "Termin został pomyślnie zarezerwowany." });
})
.WithName("BookSlot")
.WithOpenApi();

3. Nowoczesny klient konsolowy (HttpClient + System.Net.Http.Json)

W .NET 8/9 nie korzystamy już z zewnętrznej biblioteki Newtonsoft.Json ani ręcznego kodowania tablic bajtów. Wbudowana przestrzeń nazw System.Net.Http.Json zapewnia asynchroniczne metody rozszerzające GetFromJsonAsync oraz PostAsJsonAsync:

using System.Net.Http.Json;
using ProjectDefense.Core.DTOs;

using var client = new HttpClient
{
    BaseAddress = new Uri("https://localhost:7125")
};

// 1. Pobranie listy dostępnych terminów (HTTP GET)
try
{
    var slots = await client.GetFromJsonAsync<List<AvailableSlotDto>>("/api/slots/available");
    if (slots is { Count: > 0 })
    {
        foreach (var s in slots)
        {
            Console.WriteLine($"[ID: {s.SlotId}] Sala: {s.RoomName} | {s.StartTime:dd.MM.yyyy HH:mm} - {s.EndTime:HH:mm} | Prowadzący: {s.LecturerName}");
        }
    }
    else
    {
        Console.WriteLine("Brak wolnych terminów obron.");
    }

    // 2. Wysłanie żądania rezerwacji terminu (HTTP POST)
    var bookingRequest = new BookSlotRequestDto(SlotId: 12, StudentEmail: "jan.kowalski@student.pg.edu.pl");
    HttpResponseMessage response = await client.PostAsJsonAsync($"/api/slots/{bookingRequest.SlotId}/book", bookingRequest);

    if (response.IsSuccessStatusCode)
    {
        Console.WriteLine("Rezerwacja zakończona sukcesem!");
    }
    else
    {
        // Prawidłowy asynchroniczny odczyt treści błędu z odpowiedzi HTTP
        string errorPayload = await response.Content.ReadAsStringAsync();
        Console.WriteLine($"Błąd rezerwacji ({(int)response.StatusCode}): {errorPayload}");
    }
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"Błąd komunikacji sieciowej z serwerem API: {ex.Message}");
}

7. Uwierzytelnianie, Autoryzacja oparta na Rolach i Wysyłka E-mail (IEmailSender)

Cel: Konfiguracja ASP.NET Core Identity z obsługą ról (Lecturer, Student), zabezpieczenie folderów i stron Razor oraz implementacja usługi potwierdzania rejestracji przez e-mail.

1. Konfiguracja Identity z obsługą Ról i wymuszoną autoryzacją w Program.cs

Domyślna metoda AddDefaultIdentity<ApplicationUser> nie aktywuje menedżera ról (RoleManager<IdentityRole>). Należy jawnie wywołać .AddRoles<IdentityRole>():

builder.Services.AddDefaultIdentity<ApplicationUser>(options =>
{
    options.SignIn.RequireConfirmedAccount = true; // Wymaga potwierdzenia adresu e-mail
    options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();

// Definicja polityk autoryzacji i konwencji dostępu do folderów Razor Pages
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequireLecturerRole", policy => policy.RequireRole("Lecturer"));
    options.AddPolicy("RequireStudentRole", policy => policy.RequireRole("Student"));

    // FallbackPolicy: każdy niezalogowany użytkownik jest automatycznie przekierowywany
    // do strony logowania (z wyjątkiem stron oznaczonych [AllowAnonymous], np. Login/Register/API)
    options.FallbackPolicy = options.DefaultPolicy;
});

builder.Services.AddRazorPages(options =>
{
    options.Conventions.AuthorizeFolder("/Lecturer", "RequireLecturerRole");
    options.Conventions.AuthorizeFolder("/Student", "RequireStudentRole");
});

Alternatywnie pojedynczą klasę PageModel można zabezpieczyć deklaratywnie za pomocą atrybutu:

[Authorize(Roles = "Lecturer")]
public class ManageRoomsModel : PageModel { ... }

2. Implementacja IEmailSender (SendGrid / SMTP) i kodowanie linków HTML

Podczas rejestracji konta z włączoną opcją RequireConfirmedAccount = true, ASP.NET Core Identity generuje token weryfikacyjny i wywołuje metodę SendEmailAsync z interfejsu Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.

🧠 Dlaczego w linku potwierdzającym pojawia się &amp; i jak to poprawnie obsłużyć?
Domyślny szablon Identity w Register.cshtml.cs koduje wygenerowany adres URL za pomocą HtmlEncoder.Default.Encode(callbackUrl), zamieniając znak & pomiędzy parametrami userId i code na encję HTML &amp;, i przekazuje gotowy znacznik <a href='...'> jako parametr htmlMessage.
Jeśli wyślesz tę wiadomość jako zwykły tekst (plain/text) zamiast HTML (text/html), klient pocztowy nie zdekoduje &amp; z powrotem do &, co uszkodzi parametr code podczas kliknięcia! Zawsze wysyłaj wiadomość jako treść HTML (lub w przypadku prostych bramek tekstowych zdekoduj encje przez WebUtility.HtmlDecode).

Przykład produkcyjnej, w pełni asynchronicznej implementacji dla usługi SendGrid:

using Microsoft.AspNetCore.Identity.UI.Services;
using SendGrid;
using SendGrid.Helpers.Mail;

namespace ProjectDefense.Web.Services;

public class SendGridEmailSender : IEmailSender
{
    private readonly ILogger<SendGridEmailSender> _logger;
    private readonly IConfiguration _configuration;

    public SendGridEmailSender(ILogger<SendGridEmailSender> logger, IConfiguration configuration)
    {
        _logger = logger;
        _configuration = configuration;
    }

    public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage)
    {
        var apiKey = _configuration["SendGrid:ApiKey"];
        var fromEmail = _configuration["SendGrid:FromEmail"] ?? "no-reply@projectdefense.local";

        if (string.IsNullOrWhiteSpace(apiKey))
        {
            // W środowisku deweloperskim logujemy link aktywacyjny do konsoli,
            // aby umożliwić testowanie bez zewnętrznego klucza API
            _logger.LogWarning("[DEV EMAIL FALLBACK] Do: {To} | Temat: {Subject} | Treść HTML: {Body}",
                toEmail, subject, htmlMessage);
            return;
        }

        var client = new SendGridClient(apiKey);
        var from = new EmailAddress(fromEmail, "System Rezerwacji Project Defense");
        var to = new EmailAddress(toEmail);

        // Piąty parametr to htmlContent - SendGrid automatycznie ustawi nagłówek MIME text/html
        var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent: null, htmlContent: htmlMessage);
        var response = await client.SendEmailAsync(msg);

        if (response.IsSuccessStatusCode)
        {
            _logger.LogInformation("Wiadomość e-mail do {Email} została pomyślnie zakolejkowana.", toEmail);
        }
        else
        {
            _logger.LogError("Błąd wysyłki e-mail do {Email}. Kod statusu: {StatusCode}", toEmail, response.StatusCode);
        }
    }
}

Rejestracja serwisu w Program.cs:

builder.Services.AddTransient<IEmailSender, SendGridEmailSender>();

8. Generowanie i Eksport Raportów Plikowych (.txt, .xlsx, .pdf)

Cel: Strumieniowe generowanie dokumentów bezpośrednio w pamięci RAM (MemoryStream) i zwracanie ich z handlera Razor Pages za pomocą File(...) z użyciem bibliotek ClosedXML oraz QuestPDF.

dotnet add package ClosedXML
dotnet add package QuestPDF

Przykładowy handler w klasie PageModel zwracający plik na żądanie prowadzącego:

using System.Text;
using ClosedXML.Excel;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;

public async Task<IActionResult> OnGetExportAsync(int roomId, string format)
{
    var slots = await _reservationService.GetSlotsForExportAsync(roomId);

    switch (format.ToLowerInvariant())
    {
        case "txt":
            var sb = new StringBuilder();
            sb.AppendLine($"HARMONOGRAM REZERWACJI - SALA #{roomId}");
            sb.AppendLine(new string('=', 50));
            foreach (var s in slots)
            {
                sb.AppendLine($"{s.StartTime:yyyy-MM-dd HH:mm} - {s.EndTime:HH:mm} | Student: {s.StudentEmail ?? "WOLNY TERMIN"}");
            }
            byte[] txtBytes = Encoding.UTF8.GetBytes(sb.ToString());
            return File(txtBytes, "text/plain; charset=utf-8", $"Harmonogram_Sala_{roomId}.txt");

        case "xlsx":
            using (var workbook = new XLWorkbook())
            {
                var worksheet = workbook.Worksheets.Add("Rezerwacje");
                worksheet.Cell(1, 1).Value = "Początek";
                worksheet.Cell(1, 2).Value = "Koniec";
                worksheet.Cell(1, 3).Value = "Sala";
                worksheet.Cell(1, 4).Value = "Student";
                worksheet.Range("A1:D1").Style.Font.Bold = true;

                int row = 2;
                foreach (var s in slots)
                {
                    worksheet.Cell(row, 1).Value = s.StartTime.ToString("yyyy-MM-dd HH:mm");
                    worksheet.Cell(row, 2).Value = s.EndTime.ToString("HH:mm");
                    worksheet.Cell(row, 3).Value = s.RoomName;
                    worksheet.Cell(row, 4).Value = s.StudentEmail ?? "Wolny";
                    row++;
                }
                worksheet.Columns().AdjustToContents();

                using var stream = new MemoryStream();
                workbook.SaveAs(stream);
                return File(
                    stream.ToArray(),
                    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                    $"Harmonogram_Sala_{roomId}.xlsx");
            }

        case "pdf":
            // Wymagana deklaracja licencji Community dla biblioteki QuestPDF
            QuestPDF.Settings.License = LicenseType.Community;

            var document = Document.Create(container =>
            {
                container.Page(page =>
                {
                    page.Margin(40);
                    page.Size(PageSizes.A4);
                    page.Header().Text($"Harmonogram Obron Projektów – Sala #{roomId}").FontSize(18).Bold();
                    page.Content().PaddingVertical(15).Table(table =>
                    {
                        table.ColumnsDefinition(columns =>
                        {
                            columns.ConstantColumn(130);
                            columns.ConstantColumn(80);
                            columns.RelativeColumn();
                        });

                        table.Header(header =>
                        {
                            header.Cell().Text("Początek").Bold();
                            header.Cell().Text("Koniec").Bold();
                            header.Cell().Text("Przypisany Student").Bold();
                        });

                        foreach (var s in slots)
                        {
                            table.Cell().Text(s.StartTime.ToString("dd.MM.yyyy HH:mm"));
                            table.Cell().Text(s.EndTime.ToString("HH:mm"));
                            table.Cell().Text(s.StudentEmail ?? "--- WOLNY ---");
                        }
                    });
                });
            });

            byte[] pdfBytes = document.GeneratePdf();
            return File(pdfBytes, "application/pdf", $"Harmonogram_Sala_{roomId}.pdf");

        default:
            return BadRequest("Nieobsługiwany format eksportu.");
    }
}