From db5ed0c2605ec263dd0d4218705b6e6b99530f62 Mon Sep 17 00:00:00 2001 From: daniyal malik Date: Wed, 19 Mar 2025 00:00:00 +0100 Subject: [PATCH 1/9] Add Phase 3: Analytics, QR codes, and click tracking infrastructure --- .gitignore | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f98ef0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +## ── Claude Code / Anthropic AI tooling ────────────────────────────────────── +.claude/ +CLAUDE.md +.claudeignore +claude.json + +## ── .NET ───────────────────────────────────────────────────────────────────── +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates +*.userprefs + +# Rider +.idea/ +*.sln.iml + +# VS Code +.vscode/ +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# NuGet +*.nupkg +*.snupkg +**/[Pp]ackages/ +!**/[Pp]ackages/build/ +*.nuget.props +*.nuget.targets +project.lock.json +project.fragment.lock.json +artifacts/ + +# dotnet tools manifest (keep .config/dotnet-tools.json but not local tool installs) +.dotnet/ + +# Test results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.VisualState.xml +TestResult.xml +nunit-*.xml +coverage*.xml +coverage*.json +coverage*.info +*.coveragexml +*.coverage + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +## ── Secrets / Environment ──────────────────────────────────────────────────── +appsettings.Development.json +appsettings.Production.json +appsettings.Staging.json +**/secrets.json +**/*.pfx +**/*.p12 +.env +.env.* +!.env.example + +## ── OS ─────────────────────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +## ── Docker ─────────────────────────────────────────────────────────────────── +# Keep Dockerfiles; ignore local compose overrides +docker-compose.override.yml From 16189d89929933b650c5cfdc16d58cd2b951fec8 Mon Sep 17 00:00:00 2001 From: daniyal malik Date: Wed, 19 Mar 2025 00:00:00 +0100 Subject: [PATCH 2/9] Add Phase 4: rate limiting, security headers, health checks, CORS, Swagger --- .../Middleware/SecurityHeadersMiddleware.cs | 36 +++++++ src/SnipLink.Api/Program.cs | 101 +++++++++++++----- src/SnipLink.Api/SnipLink.Api.csproj | 2 + src/SnipLink.Api/appsettings.json | 3 + 4 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 src/SnipLink.Api/Middleware/SecurityHeadersMiddleware.cs diff --git a/src/SnipLink.Api/Middleware/SecurityHeadersMiddleware.cs b/src/SnipLink.Api/Middleware/SecurityHeadersMiddleware.cs new file mode 100644 index 0000000..a8faf32 --- /dev/null +++ b/src/SnipLink.Api/Middleware/SecurityHeadersMiddleware.cs @@ -0,0 +1,36 @@ +namespace SnipLink.Api.Middleware; + +public sealed class SecurityHeadersMiddleware +{ + private readonly RequestDelegate _next; + private readonly bool _isProduction; + + public SecurityHeadersMiddleware(RequestDelegate next, IWebHostEnvironment env) + { + _next = next; + _isProduction = env.IsProduction(); + } + + public async Task InvokeAsync(HttpContext context) + { + var h = context.Response.Headers; + + h["X-Content-Type-Options"] = "nosniff"; + h["X-Frame-Options"] = "DENY"; + h["X-XSS-Protection"] = "1; mode=block"; + h["Referrer-Policy"] = "strict-origin-when-cross-origin"; + h["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"; + h["Content-Security-Policy"] = + "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "frame-ancestors 'none'"; + + // HSTS only makes sense over a verified TLS connection in production. + if (_isProduction) + h["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"; + + await _next(context); + } +} diff --git a/src/SnipLink.Api/Program.cs b/src/SnipLink.Api/Program.cs index 89d9930..5d71d8e 100644 --- a/src/SnipLink.Api/Program.cs +++ b/src/SnipLink.Api/Program.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using SnipLink.Api.Data; using SnipLink.Api.Domain; +using SnipLink.Api.Middleware; using SnipLink.Api.Services; var builder = WebApplication.CreateBuilder(args); @@ -38,17 +39,17 @@ // ── Cookie auth — HttpOnly, Secure, SameSite=Strict ────────────────────────── builder.Services.ConfigureApplicationCookie(options => { - options.Cookie.HttpOnly = true; + options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; - options.Cookie.SameSite = SameSiteMode.Strict; - options.Cookie.Name = "sniplink.auth"; - options.ExpireTimeSpan = TimeSpan.FromDays(7); - options.SlidingExpiration = true; - options.LoginPath = "/api/auth/login"; - options.LogoutPath = "/api/auth/logout"; - options.AccessDeniedPath = "/api/auth/forbidden"; - - // Return 401 JSON instead of redirecting API clients + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.Name = "sniplink.auth"; + options.ExpireTimeSpan = TimeSpan.FromDays(7); + options.SlidingExpiration = true; + options.LoginPath = "/api/auth/login"; + options.LogoutPath = "/api/auth/logout"; + options.AccessDeniedPath = "/api/auth/forbidden"; + + // Return 401/403 JSON instead of redirecting API clients options.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; @@ -64,45 +65,55 @@ // ── Rate limiting ───────────────────────────────────────────────────────────── builder.Services.AddRateLimiter(options => { - // Public redirect: 60 req/min per IP (sliding window) - options.AddSlidingWindowLimiter("Redirect", opt => + // Authenticated link creation: token bucket — burst of 20, refill 10/min, queue 5 + options.AddTokenBucketLimiter("CreateLink", opt => { - opt.PermitLimit = 60; - opt.Window = TimeSpan.FromMinutes(1); - opt.SegmentsPerWindow = 6; - opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; - opt.QueueLimit = 0; + opt.TokenLimit = 20; + opt.ReplenishmentPeriod = TimeSpan.FromMinutes(1); + opt.TokensPerPeriod = 10; + opt.AutoReplenishment = true; + opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + opt.QueueLimit = 5; }); - // Authenticated link creation: 20 req/min per user - options.AddSlidingWindowLimiter("CreateLink", opt => + // Public redirect: fixed window — 100 req/s, no queue (drop immediately) + options.AddFixedWindowLimiter("Redirect", opt => { - opt.PermitLimit = 20; - opt.Window = TimeSpan.FromMinutes(1); - opt.SegmentsPerWindow = 6; + opt.PermitLimit = 100; + opt.Window = TimeSpan.FromSeconds(1); opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; opt.QueueLimit = 0; }); - // Analytics + dashboard reads: 30 req/min - options.AddSlidingWindowLimiter("Analytics", opt => + // Analytics + dashboard reads: fixed window — 60 req/min, queue 5 + options.AddFixedWindowLimiter("Analytics", opt => { - opt.PermitLimit = 30; + opt.PermitLimit = 60; opt.Window = TimeSpan.FromMinutes(1); - opt.SegmentsPerWindow = 6; opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; - opt.QueueLimit = 0; + opt.QueueLimit = 5; }); options.OnRejected = async (ctx, ct) => { - ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; ctx.HttpContext.Response.ContentType = "application/json"; await ctx.HttpContext.Response.WriteAsync( """{"error":"Too many requests. Please slow down."}""", ct); }; }); +// ── CORS ────────────────────────────────────────────────────────────────────── +var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"] + ?? throw new InvalidOperationException("Cors:AllowedOrigin is not configured."); + +builder.Services.AddCors(options => + options.AddPolicy("BlazorClient", policy => + policy.WithOrigins(allowedOrigin) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials())); + // ── Application services ────────────────────────────────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -116,15 +127,49 @@ await ctx.HttpContext.Response.WriteAsync( sp.GetRequiredService()); builder.Services.AddHostedService(); +// ── Health checks ───────────────────────────────────────────────────────────── +builder.Services.AddHealthChecks() + .AddSqlServer( + builder.Configuration.GetConnectionString("DefaultConnection")!, + name: "sql", + tags: ["db", "sql"]); + +// ── Swagger (development only) ──────────────────────────────────────────────── +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new() { Title = "SnipLink API", Version = "v1" }); + // Allow sending the auth cookie from Swagger UI + options.AddSecurityDefinition("cookieAuth", new() + { + Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey, + In = Microsoft.OpenApi.Models.ParameterLocation.Cookie, + Name = "sniplink.auth" + }); + options.AddSecurityRequirement(new() + { + [new() { Reference = new() { Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, Id = "cookieAuth" } }] = [] + }); +}); + builder.Services.AddControllers(); // ── Build ───────────────────────────────────────────────────────────────────── var app = builder.Build(); +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(options => options.SwaggerEndpoint("/swagger/v1/swagger.json", "SnipLink v1")); +} + app.UseHttpsRedirection(); +app.UseMiddleware(); +app.UseCors("BlazorClient"); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); +app.MapHealthChecks("/healthz"); app.MapControllers(); app.Run(); diff --git a/src/SnipLink.Api/SnipLink.Api.csproj b/src/SnipLink.Api/SnipLink.Api.csproj index b7dd21a..023168f 100644 --- a/src/SnipLink.Api/SnipLink.Api.csproj +++ b/src/SnipLink.Api/SnipLink.Api.csproj @@ -5,6 +5,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -12,6 +13,7 @@ + diff --git a/src/SnipLink.Api/appsettings.json b/src/SnipLink.Api/appsettings.json index fd80a77..777d5fa 100644 --- a/src/SnipLink.Api/appsettings.json +++ b/src/SnipLink.Api/appsettings.json @@ -11,5 +11,8 @@ }, "Analytics": { "IpHashSalt": "change-this-to-a-secret-value-in-production" + }, + "Cors": { + "AllowedOrigin": "https://localhost:5001" } } From c48f991f1f31c732ecb7935bfe94a72f2bd97a99 Mon Sep 17 00:00:00 2001 From: daniyal malik Date: Thu, 8 May 2025 12:00:00 +0200 Subject: [PATCH 3/9] Add Blazor UI with Radzen components, auth pages, and API client services --- src/SnipLink.Blazor/Components/App.razor | 41 ++-- .../Components/Layout/MainLayout.razor | 91 +++++-- .../Components/Layout/MinimalLayout.razor | 12 + .../Components/Pages/CreateLink.razor | 177 ++++++++++++++ .../Components/Pages/Dashboard.razor | 117 +++++++++ .../Components/Pages/LinkAnalytics.razor | 229 ++++++++++++++++++ .../Components/Pages/Login.razor | 122 ++++++++++ .../Components/Pages/MyLinks.razor | 193 +++++++++++++++ src/SnipLink.Blazor/Components/_Imports.razor | 26 +- src/SnipLink.Blazor/Program.cs | 68 +++--- .../Services/AuthStateService.cs | 55 +++++ .../Services/SnipLinkApiClient.cs | 229 ++++++++++++++++++ src/SnipLink.Blazor/SnipLink.Blazor.csproj | 8 +- src/SnipLink.Blazor/appsettings.json | 19 +- 14 files changed, 1296 insertions(+), 91 deletions(-) create mode 100644 src/SnipLink.Blazor/Components/Layout/MinimalLayout.razor create mode 100644 src/SnipLink.Blazor/Components/Pages/CreateLink.razor create mode 100644 src/SnipLink.Blazor/Components/Pages/Dashboard.razor create mode 100644 src/SnipLink.Blazor/Components/Pages/LinkAnalytics.razor create mode 100644 src/SnipLink.Blazor/Components/Pages/Login.razor create mode 100644 src/SnipLink.Blazor/Components/Pages/MyLinks.razor create mode 100644 src/SnipLink.Blazor/Services/AuthStateService.cs create mode 100644 src/SnipLink.Blazor/Services/SnipLinkApiClient.cs diff --git a/src/SnipLink.Blazor/Components/App.razor b/src/SnipLink.Blazor/Components/App.razor index f426bec..112c5fa 100644 --- a/src/SnipLink.Blazor/Components/App.razor +++ b/src/SnipLink.Blazor/Components/App.razor @@ -1,20 +1,21 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SnipLink.Blazor/Components/Layout/MainLayout.razor b/src/SnipLink.Blazor/Components/Layout/MainLayout.razor index 62e6287..7fd194e 100644 --- a/src/SnipLink.Blazor/Components/Layout/MainLayout.razor +++ b/src/SnipLink.Blazor/Components/Layout/MainLayout.razor @@ -1,23 +1,68 @@ -@inherits LayoutComponentBase - -
- - -
-
- About -
- -
- @Body -
-
-
- -
- An unhandled error has occurred. - Reload - 🗙 -
+@inherits LayoutComponentBase +@rendermode InteractiveServer +@inject AuthStateService AuthState +@inject NavigationManager Nav + + + +
+
+ + SnipLink + + @if (AuthState.IsAuthenticated) + { + Dashboard + My Links + Create Link + } +
+
+ @if (AuthState.IsAuthenticated) + { + @AuthState.CurrentUser!.Email + + } + else + { + + } +
+
+
+ + +
+ @Body +
+
+ + +
+ © @DateTime.Now.Year SnipLink — Short links, big impact. +
+
+
+ + + +@code { + protected override async Task OnInitializedAsync() + { + AuthState.OnAuthStateChanged += StateHasChanged; + await AuthState.InitializeAsync(); + } + + private async Task HandleLogout() + { + await AuthState.LogoutAsync(); + Nav.NavigateTo("/login"); + } + + public void Dispose() + { + AuthState.OnAuthStateChanged -= StateHasChanged; + } +} diff --git a/src/SnipLink.Blazor/Components/Layout/MinimalLayout.razor b/src/SnipLink.Blazor/Components/Layout/MinimalLayout.razor new file mode 100644 index 0000000..5fda892 --- /dev/null +++ b/src/SnipLink.Blazor/Components/Layout/MinimalLayout.razor @@ -0,0 +1,12 @@ +@inherits LayoutComponentBase + +
+
+ SnipLink +
+
+ @Body +
+
+ + diff --git a/src/SnipLink.Blazor/Components/Pages/CreateLink.razor b/src/SnipLink.Blazor/Components/Pages/CreateLink.razor new file mode 100644 index 0000000..eed0e6d --- /dev/null +++ b/src/SnipLink.Blazor/Components/Pages/CreateLink.razor @@ -0,0 +1,177 @@ +@page "/links/create" +@rendermode InteractiveServer +@inject AuthStateService AuthState +@inject SnipLinkApiClient ApiClient +@inject NavigationManager Nav + +Create Link — SnipLink + +@if (!AuthState.IsAuthenticated) +{ +

Redirecting...

+ return; +} + +

Create Short Link

+ +@if (createdLink is not null) +{ + +

Link Created!

+ +
+

Short URL

+ +
+ + @if (!string.IsNullOrWhiteSpace(createdLink.Title)) + { +

Title: @createdLink.Title

+ } +

Destination: + + @Truncate(createdLink.OriginalUrl, 80) + +

+

Slug: @createdLink.Slug

+ +
+ + +
+
+} +else +{ + @if (errorMessage is not null) + { + + @errorMessage + + } + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+} + +@inject IJSRuntime JS + +@code { + private bool isLoading; + private string? errorMessage; + private LinkResponse? createdLink; + private readonly FormModel model = new(); + + protected override void OnInitialized() + { + if (!AuthState.IsAuthenticated) + Nav.NavigateTo("/login"); + } + + private async Task HandleSubmit() + { + if (string.IsNullOrWhiteSpace(model.OriginalUrl)) + { + errorMessage = "Destination URL is required."; + return; + } + + errorMessage = null; + isLoading = true; + + try + { + var request = new CreateLinkRequest + { + OriginalUrl = model.OriginalUrl.Trim(), + Slug = string.IsNullOrWhiteSpace(model.Slug) ? null : model.Slug.Trim(), + Title = string.IsNullOrWhiteSpace(model.Title) ? null : model.Title.Trim(), + ExpiresAt = model.ExpiresAt + }; + + var (link, error) = await ApiClient.CreateLinkAsync(request); + if (error is not null) + { + errorMessage = error; + return; + } + + createdLink = link; + } + finally + { + isLoading = false; + } + } + + private void ResetForm() + { + model.OriginalUrl = string.Empty; + model.Slug = string.Empty; + model.Title = string.Empty; + model.ExpiresAt = null; + createdLink = null; + errorMessage = null; + } + + private async Task CopyShortUrl() + { + if (createdLink is not null) + await JS.InvokeVoidAsync("navigator.clipboard.writeText", createdLink.ShortUrl); + } + + private static string Truncate(string value, int max) => + value.Length <= max ? value : value[..max] + "…"; + + private class FormModel + { + public string OriginalUrl { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public DateTime? ExpiresAt { get; set; } + } +} diff --git a/src/SnipLink.Blazor/Components/Pages/Dashboard.razor b/src/SnipLink.Blazor/Components/Pages/Dashboard.razor new file mode 100644 index 0000000..e210c41 --- /dev/null +++ b/src/SnipLink.Blazor/Components/Pages/Dashboard.razor @@ -0,0 +1,117 @@ +@page "/" +@page "/dashboard" +@rendermode InteractiveServer +@inject AuthStateService AuthState +@inject SnipLinkApiClient ApiClient +@inject NavigationManager Nav + +Dashboard — SnipLink + +@if (!AuthState.IsAuthenticated) +{ +

Redirecting to login...

+ return; +} + +

Dashboard

+ +@if (isLoading) +{ + +} +else if (dashboard is null) +{ + + Could not load dashboard data. Make sure the API is running. + +} +else +{ + + + +

Total Links

+

@dashboard.TotalLinks.ToString("N0")

+
+
+ + +

Total Clicks

+

@dashboard.TotalClicks.ToString("N0")

+
+
+ + +

Clicks Today

+

@dashboard.ClicksToday.ToString("N0")

+
+
+
+ + +

Top Performing Links

+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+} + +@code { + private DashboardSummary? dashboard; + private bool isLoading = true; + + protected override async Task OnInitializedAsync() + { + if (!AuthState.IsAuthenticated) + { + Nav.NavigateTo("/login"); + return; + } + await LoadDashboard(); + } + + private async Task LoadDashboard() + { + isLoading = true; + dashboard = await ApiClient.GetDashboardAsync(); + isLoading = false; + } + + private static string Truncate(string value, int max) => + value.Length <= max ? value : value[..max] + "…"; +} diff --git a/src/SnipLink.Blazor/Components/Pages/LinkAnalytics.razor b/src/SnipLink.Blazor/Components/Pages/LinkAnalytics.razor new file mode 100644 index 0000000..a3e9993 --- /dev/null +++ b/src/SnipLink.Blazor/Components/Pages/LinkAnalytics.razor @@ -0,0 +1,229 @@ +@page "/links/{Id:guid}/analytics" +@rendermode InteractiveServer +@inject AuthStateService AuthState +@inject SnipLinkApiClient ApiClient +@inject NavigationManager Nav +@inject NotificationService NotificationService + +Analytics — SnipLink + +@if (!AuthState.IsAuthenticated) +{ +

Redirecting...

+ return; +} + +
+ +

Analytics@(link is not null ? $" — /{link.Slug}" : "")

+
+ +@if (isLoading) +{ + +} +else if (analytics is null) +{ + + Could not load analytics data. + +} +else +{ + + + +

Total Clicks

+

@analytics.TotalClicks.ToString("N0")

+
+
+ @if (link is not null) + { + + +

Short URL

+ + @link.ShortUrl + +
+
+ + +

Status

+ @if (link.IsActive) + { + + } + else + { + + } +
+
+ } +
+ + @* Days selector *@ +
+ Period: + @foreach (var d in new[] { 7, 14, 30, 90 }) + { + + } +
+ + @* Clicks over time *@ + @if (chartPoints.Count > 0) + { + +

Clicks Over Time

+ + + + + + + +
+ } + + + @* Device breakdown *@ + @if (analytics.DeviceBreakdown.Count > 0) + { + + +

Device Breakdown

+ + + +
+
+ } + + @* Top countries *@ + @if (analytics.TopCountries.Count > 0) + { + + +

Top Countries

+ + + + + + +
+
+ } +
+ + @* Top referrers *@ + @if (analytics.TopReferrers.Count > 0) + { + +

Top Referrers

+ + + + + + +
+ } + + @* QR Code *@ + +

QR Code

+ @if (qrCode is not null) + { +
+ QR code for @qrCode.ShortUrl +
+ } + +
+} + +@code { + [Parameter] public Guid Id { get; set; } + + private AnalyticsSummary? analytics; + private LinkResponse? link; + private QrCodeResponse? qrCode; + private List chartPoints = []; + private bool isLoading = true; + private bool isQrLoading; + private int selectedDays = 30; + + private record ChartPoint(string Label, long Clicks); + + protected override async Task OnInitializedAsync() + { + if (!AuthState.IsAuthenticated) + { + Nav.NavigateTo("/login"); + return; + } + await LoadDataAsync(); + } + + private async Task LoadDataAsync() + { + isLoading = true; + var (analyticsTask, linkTask) = ( + ApiClient.GetAnalyticsAsync(Id, selectedDays), + ApiClient.GetLinkAsync(Id)); + + await Task.WhenAll(analyticsTask, linkTask); + analytics = await analyticsTask; + link = await linkTask; + + BuildChartPoints(); + isLoading = false; + } + + private void BuildChartPoints() + { + chartPoints = analytics?.ClicksOverTime + .Select(p => new ChartPoint(p.Date.ToString("MMM d"), p.Clicks)) + .ToList() ?? []; + } + + private async Task ChangeDays(int days) + { + selectedDays = days; + isLoading = true; + analytics = await ApiClient.GetAnalyticsAsync(Id, selectedDays); + BuildChartPoints(); + isLoading = false; + } + + private async Task GenerateQrCode() + { + isQrLoading = true; + qrCode = await ApiClient.GetQrCodeAsync(Id); + if (qrCode is null) + { + NotificationService.Notify(new NotificationMessage + { + Severity = NotificationSeverity.Error, + Summary = "Failed to generate QR code", + Duration = 3000 + }); + } + isQrLoading = false; + } +} diff --git a/src/SnipLink.Blazor/Components/Pages/Login.razor b/src/SnipLink.Blazor/Components/Pages/Login.razor new file mode 100644 index 0000000..b1a570d --- /dev/null +++ b/src/SnipLink.Blazor/Components/Pages/Login.razor @@ -0,0 +1,122 @@ +@page "/login" +@layout SnipLink.Blazor.Components.Layout.MinimalLayout +@rendermode InteractiveServer +@inject AuthStateService AuthState +@inject NavigationManager Nav + +Login — SnipLink + + + +@code { + private bool isRegister; + private bool isLoading; + private string? errorMessage; + private readonly FormModel model = new(); + + protected override void OnInitialized() + { + if (AuthState.IsAuthenticated) + Nav.NavigateTo("/dashboard"); + } + + private async Task HandleSubmit() + { + errorMessage = null; + isLoading = true; + + try + { + string? error; + + if (isRegister) + { + if (string.IsNullOrWhiteSpace(model.DisplayName)) + { + errorMessage = "Display name is required."; + return; + } + error = await AuthState.RegisterAsync(new RegisterRequest + { + Email = model.Email, + Password = model.Password, + DisplayName = model.DisplayName + }); + } + else + { + error = await AuthState.LoginAsync(new LoginRequest + { + Email = model.Email, + Password = model.Password + }); + } + + if (error is not null) + { + errorMessage = error; + return; + } + + Nav.NavigateTo("/dashboard"); + } + finally + { + isLoading = false; + } + } + + private class FormModel + { + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + } +} diff --git a/src/SnipLink.Blazor/Components/Pages/MyLinks.razor b/src/SnipLink.Blazor/Components/Pages/MyLinks.razor new file mode 100644 index 0000000..345bc4d --- /dev/null +++ b/src/SnipLink.Blazor/Components/Pages/MyLinks.razor @@ -0,0 +1,193 @@ +@page "/links" +@rendermode InteractiveServer +@inject AuthStateService AuthState +@inject SnipLinkApiClient ApiClient +@inject NavigationManager Nav +@inject DialogService DialogService +@inject NotificationService NotificationService + +My Links — SnipLink + +@if (!AuthState.IsAuthenticated) +{ +

Redirecting...

+ return; +} + +
+

My Links

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +@code { + private RadzenDataGrid? grid; + private List links = []; + private int totalCount; + private bool isLoading; + private string searchTerm = string.Empty; + private System.Timers.Timer? searchDebounce; + + protected override void OnInitialized() + { + if (!AuthState.IsAuthenticated) + Nav.NavigateTo("/login"); + } + + private async Task LoadData(LoadDataArgs args) + { + isLoading = true; + try + { + var pageSize = args.Top ?? 20; + var page = args.Skip.HasValue ? (int)(args.Skip.Value / pageSize) + 1 : 1; + var result = await ApiClient.GetLinksAsync(page, pageSize, searchTerm); + links = result?.Items.ToList() ?? []; + totalCount = result?.TotalCount ?? 0; + } + finally + { + isLoading = false; + } + } + + private void OnSearchInput(ChangeEventArgs e) + { + searchTerm = e.Value?.ToString() ?? string.Empty; + searchDebounce?.Dispose(); + searchDebounce = new System.Timers.Timer(400); + searchDebounce.Elapsed += async (_, _) => + { + searchDebounce?.Dispose(); + await InvokeAsync(async () => + { + if (grid is not null) await grid.Reload(); + }); + }; + searchDebounce.AutoReset = false; + searchDebounce.Start(); + } + + private async Task ToggleLink(LinkResponse link) + { + var updated = await ApiClient.ToggleLinkAsync(link.Id); + if (updated is not null) + { + var idx = links.FindIndex(l => l.Id == link.Id); + if (idx >= 0) links[idx] = updated; + NotificationService.Notify(new NotificationMessage + { + Severity = NotificationSeverity.Success, + Summary = updated.IsActive ? "Link enabled" : "Link disabled", + Duration = 2500 + }); + StateHasChanged(); + } + } + + private async Task ConfirmDelete(LinkResponse link) + { + var confirmed = await DialogService.Confirm( + $"Delete the link \"/{link.Slug}\"? This cannot be undone.", + "Delete Link", + new ConfirmOptions { OkButtonText = "Delete", CancelButtonText = "Cancel" }); + + if (confirmed != true) return; + + var ok = await ApiClient.DeleteLinkAsync(link.Id); + if (ok) + { + links.RemoveAll(l => l.Id == link.Id); + totalCount--; + NotificationService.Notify(new NotificationMessage + { + Severity = NotificationSeverity.Info, + Summary = "Link deleted", + Duration = 2500 + }); + StateHasChanged(); + } + else + { + NotificationService.Notify(new NotificationMessage + { + Severity = NotificationSeverity.Error, + Summary = "Failed to delete link", + Duration = 3000 + }); + } + } + + private static string Truncate(string value, int max) => + value.Length <= max ? value : value[..max] + "…"; + + public void Dispose() => searchDebounce?.Dispose(); +} diff --git a/src/SnipLink.Blazor/Components/_Imports.razor b/src/SnipLink.Blazor/Components/_Imports.razor index 5f1ab2f..4746582 100644 --- a/src/SnipLink.Blazor/Components/_Imports.razor +++ b/src/SnipLink.Blazor/Components/_Imports.razor @@ -1,10 +1,16 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using static Microsoft.AspNetCore.Components.Web.RenderMode -@using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.JSInterop -@using SnipLink.Blazor -@using SnipLink.Blazor.Components +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using SnipLink.Blazor +@using SnipLink.Blazor.Components +@using SnipLink.Blazor.Services +@using SnipLink.Shared.DTOs +@using SnipLink.Shared.DTOs.Analytics +@using SnipLink.Shared.Enums +@using Radzen +@using Radzen.Blazor diff --git a/src/SnipLink.Blazor/Program.cs b/src/SnipLink.Blazor/Program.cs index 0e8134a..8d5814a 100644 --- a/src/SnipLink.Blazor/Program.cs +++ b/src/SnipLink.Blazor/Program.cs @@ -1,27 +1,41 @@ -using SnipLink.Blazor.Components; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (!app.Environment.IsDevelopment()) -{ - app.UseExceptionHandler("/Error", createScopeForErrors: true); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. - app.UseHsts(); -} - -app.UseHttpsRedirection(); - -app.UseStaticFiles(); -app.UseAntiforgery(); - -app.MapRazorComponents() - .AddInteractiveServerRenderMode(); - -app.Run(); +using Radzen; +using SnipLink.Blazor.Components; +using SnipLink.Blazor.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddRadzenComponents(); + +var apiBaseUrl = builder.Configuration["ApiBaseUrl"] ?? "https://localhost:7288"; +builder.Services.AddHttpClient("sniplink_api", client => +{ + client.BaseAddress = new Uri(apiBaseUrl); + client.Timeout = TimeSpan.FromSeconds(30); +}).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler +{ + AllowAutoRedirect = false, + UseCookies = false +}); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/src/SnipLink.Blazor/Services/AuthStateService.cs b/src/SnipLink.Blazor/Services/AuthStateService.cs new file mode 100644 index 0000000..54e4dc5 --- /dev/null +++ b/src/SnipLink.Blazor/Services/AuthStateService.cs @@ -0,0 +1,55 @@ +using SnipLink.Shared.DTOs; + +namespace SnipLink.Blazor.Services; + +/// +/// Scoped service holding authentication state for the current Blazor circuit. +/// +public class AuthStateService +{ + private readonly SnipLinkApiClient _api; + + public UserInfo? CurrentUser { get; private set; } + public bool IsAuthenticated => CurrentUser is not null; + + /// Raised whenever auth state changes (login, logout, or initial check). + public event Action? OnAuthStateChanged; + + public AuthStateService(SnipLinkApiClient api) => _api = api; + + /// + /// Checks the API for an existing session. Call once from MainLayout.OnInitializedAsync. + /// + public async Task InitializeAsync() + { + CurrentUser = await _api.GetCurrentUserAsync(); + OnAuthStateChanged?.Invoke(); + } + + /// Returns null on success, or an error message on failure. + public async Task LoginAsync(LoginRequest request) + { + var (response, error) = await _api.LoginAsync(request); + if (response is null) return error; + CurrentUser = response.User; + OnAuthStateChanged?.Invoke(); + return null; + } + + /// Returns null on success, or an error message on failure. + public async Task RegisterAsync(RegisterRequest request) + { + var (response, error) = await _api.RegisterAsync(request); + if (response is null) return error; + CurrentUser = response.User; + OnAuthStateChanged?.Invoke(); + return null; + } + + public async Task LogoutAsync() + { + await _api.LogoutAsync(); + CurrentUser = null; + OnAuthStateChanged?.Invoke(); + } +} diff --git a/src/SnipLink.Blazor/Services/SnipLinkApiClient.cs b/src/SnipLink.Blazor/Services/SnipLinkApiClient.cs new file mode 100644 index 0000000..b8d270a --- /dev/null +++ b/src/SnipLink.Blazor/Services/SnipLinkApiClient.cs @@ -0,0 +1,229 @@ +using System.Net; +using System.Net.Http.Json; +using SnipLink.Shared.DTOs; +using SnipLink.Shared.DTOs.Analytics; + +namespace SnipLink.Blazor.Services; + +/// +/// Scoped typed client wrapping all SnipLink API calls. +/// Manages the auth cookie in-memory for the duration of the Blazor circuit. +/// +public class SnipLinkApiClient +{ + private readonly HttpClient _http; + private string? _authCookieHeader; + + public SnipLinkApiClient(IHttpClientFactory factory) + { + _http = factory.CreateClient("sniplink_api"); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private HttpRequestMessage NewRequest(HttpMethod method, string url) + { + var req = new HttpRequestMessage(method, url); + if (_authCookieHeader is not null) + req.Headers.TryAddWithoutValidation("Cookie", _authCookieHeader); + return req; + } + + /// + /// Captures the auth cookie(s) from Set-Cookie headers on login/register responses + /// so subsequent requests remain authenticated for this circuit. + /// + private void CaptureCookies(HttpResponseMessage response) + { + if (response.Headers.TryGetValues("Set-Cookie", out var values)) + { + var pairs = values + .Select(v => v.Split(';')[0].Trim()) + .Where(p => p.Contains('=')); + _authCookieHeader = string.Join("; ", pairs); + } + } + + public void ClearCookies() => _authCookieHeader = null; + + // ── Auth ───────────────────────────────────────────────────────────────── + + public async Task<(AuthResponse? Result, string? Error)> RegisterAsync(RegisterRequest request) + { + var req = NewRequest(HttpMethod.Post, "api/auth/register"); + req.Content = JsonContent.Create(request); + HttpResponseMessage resp; + try { resp = await _http.SendAsync(req); } + catch { return (null, "Could not reach the server."); } + + if (!resp.IsSuccessStatusCode) + return (null, await ReadErrorAsync(resp) ?? "Registration failed."); + + CaptureCookies(resp); + return (await resp.Content.ReadFromJsonAsync(), null); + } + + public async Task<(AuthResponse? Result, string? Error)> LoginAsync(LoginRequest request) + { + var req = NewRequest(HttpMethod.Post, "api/auth/login"); + req.Content = JsonContent.Create(request); + HttpResponseMessage resp; + try { resp = await _http.SendAsync(req); } + catch { return (null, "Could not reach the server."); } + + if (!resp.IsSuccessStatusCode) + { + var error = resp.StatusCode == HttpStatusCode.Unauthorized + ? "Invalid email or password." + : await ReadErrorAsync(resp) ?? "Login failed."; + return (null, error); + } + + CaptureCookies(resp); + return (await resp.Content.ReadFromJsonAsync(), null); + } + + public async Task LogoutAsync() + { + var req = NewRequest(HttpMethod.Post, "api/auth/logout"); + try { await _http.SendAsync(req); } catch { } + ClearCookies(); + } + + public async Task GetCurrentUserAsync() + { + var req = NewRequest(HttpMethod.Get, "api/auth/me"); + try + { + var resp = await _http.SendAsync(req); + if (!resp.IsSuccessStatusCode) return null; + return await resp.Content.ReadFromJsonAsync(); + } + catch { return null; } + } + + // ── Links ───────────────────────────────────────────────────────────────── + + public async Task<(LinkResponse? Link, string? Error)> CreateLinkAsync(CreateLinkRequest request) + { + var req = NewRequest(HttpMethod.Post, "api/links"); + req.Content = JsonContent.Create(request); + HttpResponseMessage resp; + try { resp = await _http.SendAsync(req); } + catch { return (null, "Could not reach the server."); } + + if (!resp.IsSuccessStatusCode) + { + var error = resp.StatusCode switch + { + HttpStatusCode.Conflict => "That slug is already in use.", + HttpStatusCode.BadRequest => "Invalid request. Check the URL and try again.", + _ => "Failed to create link." + }; + return (null, error); + } + + return (await resp.Content.ReadFromJsonAsync(), null); + } + + public async Task GetLinksAsync(int page = 1, int pageSize = 20, string? search = null) + { + var url = $"api/links?page={page}&pageSize={pageSize}"; + if (!string.IsNullOrWhiteSpace(search)) + url += $"&search={Uri.EscapeDataString(search)}"; + + var req = NewRequest(HttpMethod.Get, url); + try + { + var resp = await _http.SendAsync(req); + if (!resp.IsSuccessStatusCode) return null; + return await resp.Content.ReadFromJsonAsync(); + } + catch { return null; } + } + + public async Task GetLinkAsync(Guid id) + { + var req = NewRequest(HttpMethod.Get, $"api/links/{id}"); + try + { + var resp = await _http.SendAsync(req); + if (!resp.IsSuccessStatusCode) return null; + return await resp.Content.ReadFromJsonAsync(); + } + catch { return null; } + } + + public async Task DeleteLinkAsync(Guid id) + { + var req = NewRequest(HttpMethod.Delete, $"api/links/{id}"); + try + { + var resp = await _http.SendAsync(req); + return resp.IsSuccessStatusCode; + } + catch { return false; } + } + + public async Task ToggleLinkAsync(Guid id) + { + var req = NewRequest(HttpMethod.Patch, $"api/links/{id}/toggle"); + try + { + var resp = await _http.SendAsync(req); + if (!resp.IsSuccessStatusCode) return null; + return await resp.Content.ReadFromJsonAsync(); + } + catch { return null; } + } + + // ── Analytics ──────────────────────────────────────────────────────────── + + public async Task GetAnalyticsAsync(Guid id, int days = 30) + { + var req = NewRequest(HttpMethod.Get, $"api/links/{id}/analytics?days={days}"); + try + { + var resp = await _http.SendAsync(req); + if (!resp.IsSuccessStatusCode) return null; + return await resp.Content.ReadFromJsonAsync(); + } + catch { return null; } + } + + public async Task GetDashboardAsync() + { + var req = NewRequest(HttpMethod.Get, "api/dashboard"); + try + { + var resp = await _http.SendAsync(req); + if (!resp.IsSuccessStatusCode) return null; + return await resp.Content.ReadFromJsonAsync(); + } + catch { return null; } + } + + public async Task GetQrCodeAsync(Guid id) + { + var req = NewRequest(HttpMethod.Get, $"api/links/{id}/qr"); + try + { + var resp = await _http.SendAsync(req); + if (!resp.IsSuccessStatusCode) return null; + return await resp.Content.ReadFromJsonAsync(); + } + catch { return null; } + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private static async Task ReadErrorAsync(HttpResponseMessage response) + { + try + { + var body = await response.Content.ReadAsStringAsync(); + return string.IsNullOrWhiteSpace(body) ? null : body; + } + catch { return null; } + } +} diff --git a/src/SnipLink.Blazor/SnipLink.Blazor.csproj b/src/SnipLink.Blazor/SnipLink.Blazor.csproj index 2f301f7..02b8703 100644 --- a/src/SnipLink.Blazor/SnipLink.Blazor.csproj +++ b/src/SnipLink.Blazor/SnipLink.Blazor.csproj @@ -1,7 +1,11 @@ - - + + + + + + diff --git a/src/SnipLink.Blazor/appsettings.json b/src/SnipLink.Blazor/appsettings.json index 4d56694..b5d3a43 100644 --- a/src/SnipLink.Blazor/appsettings.json +++ b/src/SnipLink.Blazor/appsettings.json @@ -1,9 +1,10 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ApiBaseUrl": "https://localhost:7288" +} From c20773cd8bc8c4d9a1c585e2e2a564604edcca4d Mon Sep 17 00:00:00 2001 From: daniyal malik Date: Tue, 3 Jun 2025 12:00:00 +0200 Subject: [PATCH 4/9] Add Blazor UI pages, API configuration, and SQLite migrations --- .../20260329223229_InitialCreate.Designer.cs | 451 ------------------ .../20260329223229_InitialCreate.cs | 321 ------------- .../Migrations/AppDbContextModelSnapshot.cs | 448 ----------------- src/SnipLink.Api/Program.cs | 7 +- .../Properties/launchSettings.json | 4 +- src/SnipLink.Api/SnipLink.Api.csproj | 24 +- src/SnipLink.Api/appsettings.json | 4 +- .../Components/Layout/MainLayout.razor | 2 +- .../Components/Pages/Counter.razor | 19 - .../Components/Pages/CreateLink.razor | 1 - .../Components/Pages/Dashboard.razor | 1 - .../Components/Pages/Home.razor | 7 - .../Components/Pages/LinkAnalytics.razor | 1 - .../Components/Pages/Login.razor | 1 - .../Components/Pages/MyLinks.razor | 1 - .../Components/Pages/Weather.razor | 64 --- src/SnipLink.Blazor/Components/_Imports.razor | 1 + 17 files changed, 20 insertions(+), 1337 deletions(-) delete mode 100644 src/SnipLink.Api/Data/Migrations/20260329223229_InitialCreate.Designer.cs delete mode 100644 src/SnipLink.Api/Data/Migrations/20260329223229_InitialCreate.cs delete mode 100644 src/SnipLink.Api/Data/Migrations/AppDbContextModelSnapshot.cs delete mode 100644 src/SnipLink.Blazor/Components/Pages/Counter.razor delete mode 100644 src/SnipLink.Blazor/Components/Pages/Home.razor delete mode 100644 src/SnipLink.Blazor/Components/Pages/Weather.razor diff --git a/src/SnipLink.Api/Data/Migrations/20260329223229_InitialCreate.Designer.cs b/src/SnipLink.Api/Data/Migrations/20260329223229_InitialCreate.Designer.cs deleted file mode 100644 index 1320b18..0000000 --- a/src/SnipLink.Api/Data/Migrations/20260329223229_InitialCreate.Designer.cs +++ /dev/null @@ -1,451 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using SnipLink.Api.Data; - -#nullable disable - -namespace SnipLink.Api.Data.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260329223229_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.25") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("GETUTCDATE()"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.BlockedSlug", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("GETUTCDATE()"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Reason") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.HasKey("Id"); - - b.HasIndex("Pattern") - .IsUnique() - .HasDatabaseName("IX_BlockedSlugs_Pattern"); - - b.ToTable("BlockedSlugs"); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ClickEvent", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier") - .HasDefaultValueSql("NEWID()"); - - b.Property("ClickedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("GETUTCDATE()"); - - b.Property("Country") - .HasMaxLength(2) - .HasColumnType("nvarchar(2)"); - - b.Property("DeviceType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("nvarchar(10)"); - - b.Property("IpHash") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Referrer") - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("ShortLinkId") - .HasColumnType("uniqueidentifier"); - - b.Property("UserAgent") - .HasMaxLength(512) - .HasColumnType("nvarchar(512)"); - - b.HasKey("Id"); - - b.HasIndex("ShortLinkId", "ClickedAt") - .HasDatabaseName("IX_ClickEvents_ShortLinkId_ClickedAt"); - - b.ToTable("ClickEvents"); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier") - .HasDefaultValueSql("NEWID()"); - - b.Property("ClickCount") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("GETUTCDATE()"); - - b.Property("ExpiresAt") - .HasColumnType("datetime2"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("bit") - .HasDefaultValue(true); - - b.Property("OriginalUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("OwnerId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.Property("Slug") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Title") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("OwnerId"); - - b.HasIndex("Slug") - .IsUnique() - .HasDatabaseName("IX_ShortLinks_Slug"); - - b.ToTable("ShortLinks"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ClickEvent", b => - { - b.HasOne("SnipLink.Api.Domain.ShortLink", "ShortLink") - .WithMany("Clicks") - .HasForeignKey("ShortLinkId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ShortLink"); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => - { - b.HasOne("SnipLink.Api.Domain.ApplicationUser", "Owner") - .WithMany("Links") - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Owner"); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ApplicationUser", b => - { - b.Navigation("Links"); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => - { - b.Navigation("Clicks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/SnipLink.Api/Data/Migrations/20260329223229_InitialCreate.cs b/src/SnipLink.Api/Data/Migrations/20260329223229_InitialCreate.cs deleted file mode 100644 index aec34c2..0000000 --- a/src/SnipLink.Api/Data/Migrations/20260329223229_InitialCreate.cs +++ /dev/null @@ -1,321 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace SnipLink.Api.Data.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "AspNetRoles", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoles", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetUsers", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - DisplayName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"), - UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "bit", nullable: false), - PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), - SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), - TwoFactorEnabled = table.Column(type: "bit", nullable: false), - LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), - LockoutEnabled = table.Column(type: "bit", nullable: false), - AccessFailedCount = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUsers", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "BlockedSlugs", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Pattern = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Reason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), - CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()") - }, - constraints: table => - { - table.PrimaryKey("PK_BlockedSlugs", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetRoleClaims", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - RoleId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserClaims", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - UserId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetUserClaims_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserLogins", - columns: table => new - { - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), - ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), - UserId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_AspNetUserLogins_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserRoles", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - RoleId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserTokens", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(450)", nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_AspNetUserTokens_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "ShortLinks", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWID()"), - Slug = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), - OriginalUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), - Title = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - IsActive = table.Column(type: "bit", nullable: false, defaultValue: true), - CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"), - ExpiresAt = table.Column(type: "datetime2", nullable: true), - ClickCount = table.Column(type: "bigint", nullable: false, defaultValue: 0L), - OwnerId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ShortLinks", x => x.Id); - table.ForeignKey( - name: "FK_ShortLinks_AspNetUsers_OwnerId", - column: x => x.OwnerId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "ClickEvents", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWID()"), - ClickedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"), - Referrer = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), - UserAgent = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), - IpHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), - Country = table.Column(type: "nvarchar(2)", maxLength: 2, nullable: true), - DeviceType = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: false), - ShortLinkId = table.Column(type: "uniqueidentifier", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ClickEvents", x => x.Id); - table.ForeignKey( - name: "FK_ClickEvents_ShortLinks_ShortLinkId", - column: x => x.ShortLinkId, - principalTable: "ShortLinks", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_AspNetRoleClaims_RoleId", - table: "AspNetRoleClaims", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - table: "AspNetRoles", - column: "NormalizedName", - unique: true, - filter: "[NormalizedName] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserClaims_UserId", - table: "AspNetUserClaims", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserLogins_UserId", - table: "AspNetUserLogins", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserRoles_RoleId", - table: "AspNetUserRoles", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "EmailIndex", - table: "AspNetUsers", - column: "NormalizedEmail"); - - migrationBuilder.CreateIndex( - name: "UserNameIndex", - table: "AspNetUsers", - column: "NormalizedUserName", - unique: true, - filter: "[NormalizedUserName] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_BlockedSlugs_Pattern", - table: "BlockedSlugs", - column: "Pattern", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_ClickEvents_ShortLinkId_ClickedAt", - table: "ClickEvents", - columns: new[] { "ShortLinkId", "ClickedAt" }); - - migrationBuilder.CreateIndex( - name: "IX_ShortLinks_OwnerId", - table: "ShortLinks", - column: "OwnerId"); - - migrationBuilder.CreateIndex( - name: "IX_ShortLinks_Slug", - table: "ShortLinks", - column: "Slug", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AspNetRoleClaims"); - - migrationBuilder.DropTable( - name: "AspNetUserClaims"); - - migrationBuilder.DropTable( - name: "AspNetUserLogins"); - - migrationBuilder.DropTable( - name: "AspNetUserRoles"); - - migrationBuilder.DropTable( - name: "AspNetUserTokens"); - - migrationBuilder.DropTable( - name: "BlockedSlugs"); - - migrationBuilder.DropTable( - name: "ClickEvents"); - - migrationBuilder.DropTable( - name: "AspNetRoles"); - - migrationBuilder.DropTable( - name: "ShortLinks"); - - migrationBuilder.DropTable( - name: "AspNetUsers"); - } - } -} diff --git a/src/SnipLink.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/src/SnipLink.Api/Data/Migrations/AppDbContextModelSnapshot.cs deleted file mode 100644 index c0adde1..0000000 --- a/src/SnipLink.Api/Data/Migrations/AppDbContextModelSnapshot.cs +++ /dev/null @@ -1,448 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using SnipLink.Api.Data; - -#nullable disable - -namespace SnipLink.Api.Data.Migrations -{ - [DbContext(typeof(AppDbContext))] - partial class AppDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.25") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("GETUTCDATE()"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.BlockedSlug", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("GETUTCDATE()"); - - b.Property("Pattern") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.Property("Reason") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.HasKey("Id"); - - b.HasIndex("Pattern") - .IsUnique() - .HasDatabaseName("IX_BlockedSlugs_Pattern"); - - b.ToTable("BlockedSlugs"); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ClickEvent", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier") - .HasDefaultValueSql("NEWID()"); - - b.Property("ClickedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("GETUTCDATE()"); - - b.Property("Country") - .HasMaxLength(2) - .HasColumnType("nvarchar(2)"); - - b.Property("DeviceType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("nvarchar(10)"); - - b.Property("IpHash") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Referrer") - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("ShortLinkId") - .HasColumnType("uniqueidentifier"); - - b.Property("UserAgent") - .HasMaxLength(512) - .HasColumnType("nvarchar(512)"); - - b.HasKey("Id"); - - b.HasIndex("ShortLinkId", "ClickedAt") - .HasDatabaseName("IX_ClickEvents_ShortLinkId_ClickedAt"); - - b.ToTable("ClickEvents"); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier") - .HasDefaultValueSql("NEWID()"); - - b.Property("ClickCount") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasDefaultValue(0L); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("GETUTCDATE()"); - - b.Property("ExpiresAt") - .HasColumnType("datetime2"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("bit") - .HasDefaultValue(true); - - b.Property("OriginalUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("OwnerId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.Property("Slug") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("nvarchar(50)"); - - b.Property("Title") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("OwnerId"); - - b.HasIndex("Slug") - .IsUnique() - .HasDatabaseName("IX_ShortLinks_Slug"); - - b.ToTable("ShortLinks"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ClickEvent", b => - { - b.HasOne("SnipLink.Api.Domain.ShortLink", "ShortLink") - .WithMany("Clicks") - .HasForeignKey("ShortLinkId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ShortLink"); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => - { - b.HasOne("SnipLink.Api.Domain.ApplicationUser", "Owner") - .WithMany("Links") - .HasForeignKey("OwnerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Owner"); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ApplicationUser", b => - { - b.Navigation("Links"); - }); - - modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => - { - b.Navigation("Clicks"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/SnipLink.Api/Program.cs b/src/SnipLink.Api/Program.cs index 5d71d8e..ead6bdb 100644 --- a/src/SnipLink.Api/Program.cs +++ b/src/SnipLink.Api/Program.cs @@ -11,7 +11,7 @@ // ── Database ────────────────────────────────────────────────────────────────── builder.Services.AddDbContext(options => - options.UseSqlServer( + options.UseSqlite( builder.Configuration.GetConnectionString("DefaultConnection"), sql => sql.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName) )); @@ -129,10 +129,7 @@ await ctx.HttpContext.Response.WriteAsync( // ── Health checks ───────────────────────────────────────────────────────────── builder.Services.AddHealthChecks() - .AddSqlServer( - builder.Configuration.GetConnectionString("DefaultConnection")!, - name: "sql", - tags: ["db", "sql"]); + .AddDbContextCheck(name: "db", tags: ["db", "sqlite"]); // ── Swagger (development only) ──────────────────────────────────────────────── builder.Services.AddEndpointsApiExplorer(); diff --git a/src/SnipLink.Api/Properties/launchSettings.json b/src/SnipLink.Api/Properties/launchSettings.json index 56a0d0d..01ae217 100644 --- a/src/SnipLink.Api/Properties/launchSettings.json +++ b/src/SnipLink.Api/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "swagger", "applicationUrl": "http://localhost:5121", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -23,7 +23,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchUrl": "swagger", "applicationUrl": "https://localhost:7288;http://localhost:5121", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/SnipLink.Api/SnipLink.Api.csproj b/src/SnipLink.Api/SnipLink.Api.csproj index 023168f..1671265 100644 --- a/src/SnipLink.Api/SnipLink.Api.csproj +++ b/src/SnipLink.Api/SnipLink.Api.csproj @@ -1,19 +1,19 @@ - - + + - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/src/SnipLink.Api/appsettings.json b/src/SnipLink.Api/appsettings.json index 777d5fa..4804314 100644 --- a/src/SnipLink.Api/appsettings.json +++ b/src/SnipLink.Api/appsettings.json @@ -7,12 +7,12 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=SnipLinkDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" + "DefaultConnection": "Data Source=sniplink.db" }, "Analytics": { "IpHashSalt": "change-this-to-a-secret-value-in-production" }, "Cors": { - "AllowedOrigin": "https://localhost:5001" + "AllowedOrigin": "https://localhost:7129" } } diff --git a/src/SnipLink.Blazor/Components/Layout/MainLayout.razor b/src/SnipLink.Blazor/Components/Layout/MainLayout.razor index 7fd194e..326aa46 100644 --- a/src/SnipLink.Blazor/Components/Layout/MainLayout.razor +++ b/src/SnipLink.Blazor/Components/Layout/MainLayout.razor @@ -1,5 +1,5 @@ + @inherits LayoutComponentBase -@rendermode InteractiveServer @inject AuthStateService AuthState @inject NavigationManager Nav diff --git a/src/SnipLink.Blazor/Components/Pages/Counter.razor b/src/SnipLink.Blazor/Components/Pages/Counter.razor deleted file mode 100644 index ba08d04..0000000 --- a/src/SnipLink.Blazor/Components/Pages/Counter.razor +++ /dev/null @@ -1,19 +0,0 @@ -@page "/counter" -@rendermode InteractiveServer - -Counter - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/src/SnipLink.Blazor/Components/Pages/CreateLink.razor b/src/SnipLink.Blazor/Components/Pages/CreateLink.razor index eed0e6d..4204a0c 100644 --- a/src/SnipLink.Blazor/Components/Pages/CreateLink.razor +++ b/src/SnipLink.Blazor/Components/Pages/CreateLink.razor @@ -1,5 +1,4 @@ @page "/links/create" -@rendermode InteractiveServer @inject AuthStateService AuthState @inject SnipLinkApiClient ApiClient @inject NavigationManager Nav diff --git a/src/SnipLink.Blazor/Components/Pages/Dashboard.razor b/src/SnipLink.Blazor/Components/Pages/Dashboard.razor index e210c41..429972b 100644 --- a/src/SnipLink.Blazor/Components/Pages/Dashboard.razor +++ b/src/SnipLink.Blazor/Components/Pages/Dashboard.razor @@ -1,6 +1,5 @@ @page "/" @page "/dashboard" -@rendermode InteractiveServer @inject AuthStateService AuthState @inject SnipLinkApiClient ApiClient @inject NavigationManager Nav diff --git a/src/SnipLink.Blazor/Components/Pages/Home.razor b/src/SnipLink.Blazor/Components/Pages/Home.razor deleted file mode 100644 index df05023..0000000 --- a/src/SnipLink.Blazor/Components/Pages/Home.razor +++ /dev/null @@ -1,7 +0,0 @@ -@page "/" - -Home - -

Hello, world!

- -Welcome to your new app. diff --git a/src/SnipLink.Blazor/Components/Pages/LinkAnalytics.razor b/src/SnipLink.Blazor/Components/Pages/LinkAnalytics.razor index a3e9993..63568aa 100644 --- a/src/SnipLink.Blazor/Components/Pages/LinkAnalytics.razor +++ b/src/SnipLink.Blazor/Components/Pages/LinkAnalytics.razor @@ -1,5 +1,4 @@ @page "/links/{Id:guid}/analytics" -@rendermode InteractiveServer @inject AuthStateService AuthState @inject SnipLinkApiClient ApiClient @inject NavigationManager Nav diff --git a/src/SnipLink.Blazor/Components/Pages/Login.razor b/src/SnipLink.Blazor/Components/Pages/Login.razor index b1a570d..f06c837 100644 --- a/src/SnipLink.Blazor/Components/Pages/Login.razor +++ b/src/SnipLink.Blazor/Components/Pages/Login.razor @@ -1,6 +1,5 @@ @page "/login" @layout SnipLink.Blazor.Components.Layout.MinimalLayout -@rendermode InteractiveServer @inject AuthStateService AuthState @inject NavigationManager Nav diff --git a/src/SnipLink.Blazor/Components/Pages/MyLinks.razor b/src/SnipLink.Blazor/Components/Pages/MyLinks.razor index 345bc4d..ff407c5 100644 --- a/src/SnipLink.Blazor/Components/Pages/MyLinks.razor +++ b/src/SnipLink.Blazor/Components/Pages/MyLinks.razor @@ -1,5 +1,4 @@ @page "/links" -@rendermode InteractiveServer @inject AuthStateService AuthState @inject SnipLinkApiClient ApiClient @inject NavigationManager Nav diff --git a/src/SnipLink.Blazor/Components/Pages/Weather.razor b/src/SnipLink.Blazor/Components/Pages/Weather.razor deleted file mode 100644 index 33631ab..0000000 --- a/src/SnipLink.Blazor/Components/Pages/Weather.razor +++ /dev/null @@ -1,64 +0,0 @@ -@page "/weather" -@attribute [StreamRendering] - -Weather - -

Weather

- -

This component demonstrates showing data.

- -@if (forecasts == null) -{ -

Loading...

-} -else -{ - - - - - - - - - - - @foreach (var forecast in forecasts) - { - - - - - - - } - -
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
-} - -@code { - private WeatherForecast[]? forecasts; - - protected override async Task OnInitializedAsync() - { - // Simulate asynchronous loading to demonstrate streaming rendering - await Task.Delay(500); - - var startDate = DateOnly.FromDateTime(DateTime.Now); - var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; - forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = startDate.AddDays(index), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] - }).ToArray(); - } - - private class WeatherForecast - { - public DateOnly Date { get; set; } - public int TemperatureC { get; set; } - public string? Summary { get; set; } - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - } -} diff --git a/src/SnipLink.Blazor/Components/_Imports.razor b/src/SnipLink.Blazor/Components/_Imports.razor index 4746582..d2a1ea3 100644 --- a/src/SnipLink.Blazor/Components/_Imports.razor +++ b/src/SnipLink.Blazor/Components/_Imports.razor @@ -4,6 +4,7 @@ @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using static Microsoft.AspNetCore.Components.Web.RenderMode +@rendermode InteractiveServer @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using SnipLink.Blazor From aefc85add27216ef20413830da15ede387e06e34 Mon Sep 17 00:00:00 2001 From: daniyal malik Date: Thu, 19 Jun 2025 12:00:00 +0200 Subject: [PATCH 5/9] Add xUnit unit and integration tests - SlugGeneratorTests: length, uniqueness, valid/invalid slug validation - AbuseDetectionServiceTests: URL safety (schemes, extensions) - LinkServiceTests: create/delete with SQLite in-memory + SqliteTestDbContext - ApiIntegrationTests: register, auth guard, create link, redirect, 404 - 34/34 tests passing --- .../Integration/ApiIntegrationTests.cs | 119 ++++++++++++++++ .../Integration/SnipLinkWebAppFactory.cs | 58 ++++++++ src/SnipLink.Tests/SnipLink.Tests.csproj | 8 +- .../Unit/AbuseDetectionServiceTests.cs | 46 +++++++ src/SnipLink.Tests/Unit/LinkServiceTests.cs | 128 ++++++++++++++++++ src/SnipLink.Tests/Unit/SlugGeneratorTests.cs | 49 +++++++ .../Unit/SqliteTestDbContext.cs | 50 +++++++ src/SnipLink.Tests/UnitTest1.cs | 10 -- 8 files changed, 455 insertions(+), 13 deletions(-) create mode 100644 src/SnipLink.Tests/Integration/ApiIntegrationTests.cs create mode 100644 src/SnipLink.Tests/Integration/SnipLinkWebAppFactory.cs create mode 100644 src/SnipLink.Tests/Unit/AbuseDetectionServiceTests.cs create mode 100644 src/SnipLink.Tests/Unit/LinkServiceTests.cs create mode 100644 src/SnipLink.Tests/Unit/SlugGeneratorTests.cs create mode 100644 src/SnipLink.Tests/Unit/SqliteTestDbContext.cs delete mode 100644 src/SnipLink.Tests/UnitTest1.cs diff --git a/src/SnipLink.Tests/Integration/ApiIntegrationTests.cs b/src/SnipLink.Tests/Integration/ApiIntegrationTests.cs new file mode 100644 index 0000000..e47e1a1 --- /dev/null +++ b/src/SnipLink.Tests/Integration/ApiIntegrationTests.cs @@ -0,0 +1,119 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using SnipLink.Shared.DTOs; + +namespace SnipLink.Tests.Integration; + +[Trait("Category", "Integration")] +public sealed class ApiIntegrationTests : IClassFixture +{ + private readonly SnipLinkWebAppFactory _factory; + + public ApiIntegrationTests(SnipLinkWebAppFactory factory) + { + _factory = factory; + } + + // ── Auth ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Register_ValidRequest_ReturnsOkWithUser() + { + var client = _factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest + { + Email = $"reg-{Guid.NewGuid()}@example.com", + Password = "Test@Password1!", + DisplayName = "Test User" + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body?.User); + Assert.NotEmpty(body.User.Email); + } + + // ── Links ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateLink_WithoutAuth_Returns401() + { + var client = _factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/links", + new CreateLinkRequest { OriginalUrl = "https://example.com" }); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task CreateLink_WithAuth_Returns201WithSlug() + { + var client = await CreateAuthenticatedClientAsync(); + + var response = await client.PostAsJsonAsync("/api/links", + new CreateLinkRequest { OriginalUrl = "https://example.com" }); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.NotEmpty(body.Slug); + Assert.Equal("https://example.com", body.OriginalUrl); + } + + // ── Redirect ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Redirect_ExistingSlug_Returns301ToOriginalUrl() + { + // Create a link first + var authClient = await CreateAuthenticatedClientAsync(); + var createResp = await authClient.PostAsJsonAsync("/api/links", + new CreateLinkRequest { OriginalUrl = "https://example.com/target" }); + createResp.EnsureSuccessStatusCode(); + var link = await createResp.Content.ReadFromJsonAsync(); + + // Follow-redirect is off so we can inspect the 301 + var noRedirectClient = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var response = await noRedirectClient.GetAsync($"/{link!.Slug}"); + + Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode); + Assert.Equal("https://example.com/target", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task Redirect_NonExistentSlug_Returns404() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var response = await client.GetAsync("/this-slug-does-not-exist"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// + /// Registers a fresh user and returns the with the + /// resulting auth cookie already stored in its cookie container. + /// + private async Task CreateAuthenticatedClientAsync() + { + var client = _factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest + { + Email = $"auth-{Guid.NewGuid()}@example.com", + Password = "Test@Password1!", + DisplayName = "Auth User" + }); + response.EnsureSuccessStatusCode(); + + return client; + } +} diff --git a/src/SnipLink.Tests/Integration/SnipLinkWebAppFactory.cs b/src/SnipLink.Tests/Integration/SnipLinkWebAppFactory.cs new file mode 100644 index 0000000..ae6903b --- /dev/null +++ b/src/SnipLink.Tests/Integration/SnipLinkWebAppFactory.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SnipLink.Api.Data; + +namespace SnipLink.Tests.Integration; + +public sealed class SnipLinkWebAppFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly string _dbName = Guid.NewGuid().ToString(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Provide required config values that may not be in the test content root + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Cors:AllowedOrigin"] = "https://localhost:7129" + }); + }); + + builder.ConfigureServices(services => + { + // Replace SQLite with InMemory + var dbDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (dbDescriptor != null) + services.Remove(dbDescriptor); + + services.AddDbContext(options => + options.UseInMemoryDatabase(_dbName)); + + // Allow auth cookies over HTTP so the test HttpClient can store and send them + services.PostConfigure( + IdentityConstants.ApplicationScheme, + options => options.Cookie.SecurePolicy = CookieSecurePolicy.None); + }); + } + + public async Task InitializeAsync() + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + } + + Task IAsyncLifetime.DisposeAsync() + { + Dispose(); + return Task.CompletedTask; + } +} diff --git a/src/SnipLink.Tests/SnipLink.Tests.csproj b/src/SnipLink.Tests/SnipLink.Tests.csproj index 667bd42..7b090f6 100644 --- a/src/SnipLink.Tests/SnipLink.Tests.csproj +++ b/src/SnipLink.Tests/SnipLink.Tests.csproj @@ -11,8 +11,10 @@ + + @@ -21,9 +23,9 @@
- - - + + +
diff --git a/src/SnipLink.Tests/Unit/AbuseDetectionServiceTests.cs b/src/SnipLink.Tests/Unit/AbuseDetectionServiceTests.cs new file mode 100644 index 0000000..67e127f --- /dev/null +++ b/src/SnipLink.Tests/Unit/AbuseDetectionServiceTests.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using SnipLink.Api.Data; +using SnipLink.Api.Services; + +namespace SnipLink.Tests.Unit; + +[Trait("Category", "Unit")] +public sealed class AbuseDetectionServiceTests : IDisposable +{ + private readonly AppDbContext _db; + private readonly AbuseDetectionService _sut; + + public AbuseDetectionServiceTests() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _db = new AppDbContext(opts); + _sut = new AbuseDetectionService(_db); + } + + public void Dispose() => _db.Dispose(); + + [Fact] + public void IsUrlSafe_AcceptsHttpsUrl() + { + Assert.True(_sut.IsUrlSafe("https://example.com")); + } + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("data:text/html,

xss

")] + public void IsUrlSafe_RejectsDangerousSchemes(string url) + { + Assert.False(_sut.IsUrlSafe(url)); + } + + [Theory] + [InlineData("https://example.com/malware.exe")] + [InlineData("https://example.com/script.bat")] + [InlineData("https://example.com/run.ps1")] + public void IsUrlSafe_RejectsDangerousExtensions(string url) + { + Assert.False(_sut.IsUrlSafe(url)); + } +} diff --git a/src/SnipLink.Tests/Unit/LinkServiceTests.cs b/src/SnipLink.Tests/Unit/LinkServiceTests.cs new file mode 100644 index 0000000..6bbeede --- /dev/null +++ b/src/SnipLink.Tests/Unit/LinkServiceTests.cs @@ -0,0 +1,128 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using SnipLink.Api.Data; +using SnipLink.Api.Services; +using SnipLink.Shared.Common; +using SnipLink.Shared.DTOs; + +namespace SnipLink.Tests.Unit; + +[Trait("Category", "Unit")] +public sealed class LinkServiceTests : IDisposable +{ + // SQLite in-memory is used instead of EF InMemory because LinkService uses + // ExecuteDeleteAsync (bulk delete), which EF InMemory does not support. + private readonly SqliteConnection _connection; + private readonly AppDbContext _db; + private readonly IAbuseDetectionService _abuse; + private readonly LinkService _sut; + + private const string UserId = "user-1"; + private const string OtherUserId = "user-2"; + private const string BaseUrl = "https://snip.test"; + + public LinkServiceTests() + { + // Keep the connection open so the in-memory database persists for the test lifetime. + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + // Disable FK enforcement: unit tests use fake OwnerId strings without + // a corresponding AspNetUsers row, so FK constraints would fail. + using var pragma = _connection.CreateCommand(); + pragma.CommandText = "PRAGMA foreign_keys = OFF"; + pragma.ExecuteNonQuery(); + + var opts = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + _db = new SqliteTestDbContext(opts); + _db.Database.EnsureCreated(); + + _abuse = Substitute.For(); + _abuse.IsUrlSafe(Arg.Any()).Returns(true); + _abuse.IsSlugBlockedAsync(Arg.Any(), Arg.Any()).Returns(false); + + _sut = new LinkService(_db, new SlugGenerator(), _abuse); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ── CreateAsync ─────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateAsync_WithValidUrl_ReturnsSuccessWithNonEmptySlug() + { + var result = await _sut.CreateAsync( + new CreateLinkRequest { OriginalUrl = "https://example.com" }, UserId, BaseUrl); + + var success = Assert.IsType.Success>(result); + Assert.NotEmpty(success.Value.Slug); + Assert.Equal("https://example.com", success.Value.OriginalUrl); + } + + [Fact] + public async Task CreateAsync_WithCustomSlug_UsesProvidedSlug() + { + var result = await _sut.CreateAsync( + new CreateLinkRequest { OriginalUrl = "https://example.com", Slug = "my-link" }, + UserId, BaseUrl); + + var success = Assert.IsType.Success>(result); + Assert.Equal("my-link", success.Value.Slug); + } + + [Fact] + public async Task CreateAsync_WithDuplicateSlug_ReturnsConflict() + { + var request = new CreateLinkRequest { OriginalUrl = "https://example.com", Slug = "taken" }; + await _sut.CreateAsync(request, UserId, BaseUrl); + + var result = await _sut.CreateAsync(request, UserId, BaseUrl); + + Assert.IsType.Conflict>(result); + } + + [Theory] + [InlineData("ftp://example.com")] + [InlineData("not-a-url")] + [InlineData("example.com")] + public async Task CreateAsync_WithInvalidUrl_ReturnsInvalid(string url) + { + var result = await _sut.CreateAsync( + new CreateLinkRequest { OriginalUrl = url }, UserId, BaseUrl); + + Assert.IsType.Invalid>(result); + } + + // ── DeleteAsync ─────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteAsync_OwnLink_ReturnsTrue() + { + var created = (ServiceResult.Success) + await _sut.CreateAsync( + new CreateLinkRequest { OriginalUrl = "https://example.com" }, UserId, BaseUrl); + + var deleted = await _sut.DeleteAsync(created.Value.Id, UserId); + + Assert.True(deleted); + } + + [Fact] + public async Task DeleteAsync_OtherUserLink_ReturnsFalse() + { + var created = (ServiceResult.Success) + await _sut.CreateAsync( + new CreateLinkRequest { OriginalUrl = "https://example.com" }, UserId, BaseUrl); + + var deleted = await _sut.DeleteAsync(created.Value.Id, OtherUserId); + + Assert.False(deleted); + } +} diff --git a/src/SnipLink.Tests/Unit/SlugGeneratorTests.cs b/src/SnipLink.Tests/Unit/SlugGeneratorTests.cs new file mode 100644 index 0000000..437dfcf --- /dev/null +++ b/src/SnipLink.Tests/Unit/SlugGeneratorTests.cs @@ -0,0 +1,49 @@ +using SnipLink.Api.Services; + +namespace SnipLink.Tests.Unit; + +[Trait("Category", "Unit")] +public sealed class SlugGeneratorTests +{ + private readonly SlugGenerator _sut = new(); + + [Theory] + [InlineData(3)] + [InlineData(7)] + [InlineData(20)] + [InlineData(50)] + public void Generate_ReturnsSlugOfRequestedLength(int length) + { + var slug = _sut.Generate(length); + Assert.Equal(length, slug.Length); + } + + [Fact] + public void Generate_ProducesUniqueSlugs() + { + var slugs = Enumerable.Range(0, 100).Select(_ => _sut.Generate()).ToHashSet(); + Assert.True(slugs.Count >= 95, $"Expected ≥95 unique slugs, got {slugs.Count}"); + } + + [Theory] + [InlineData("abc")] + [InlineData("my-link")] + [InlineData("a1b2c3")] + public void IsValid_AcceptsValidSlugs(string slug) + { + Assert.True(_sut.IsValid(slug)); + } + + [Theory] + [InlineData("ab")] // too short + [InlineData("")] // empty + [InlineData("-abc")] // starts with hyphen + [InlineData("abc-")] // ends with hyphen + [InlineData("ABC")] // uppercase + [InlineData("ab cd")] // space + [InlineData("ab!cd")] // special char + public void IsValid_RejectsInvalidSlugs(string slug) + { + Assert.False(_sut.IsValid(slug)); + } +} diff --git a/src/SnipLink.Tests/Unit/SqliteTestDbContext.cs b/src/SnipLink.Tests/Unit/SqliteTestDbContext.cs new file mode 100644 index 0000000..be0d225 --- /dev/null +++ b/src/SnipLink.Tests/Unit/SqliteTestDbContext.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ValueGeneration; +using SnipLink.Api.Data; +using SnipLink.Api.Domain; + +namespace SnipLink.Tests.Unit; + +/// +/// AppDbContext override that replaces SQL Server-specific defaults +/// (NEWID, GETUTCDATE) with SQLite-compatible equivalents so that +/// SQLite in-memory tests can run ExecuteDeleteAsync / ExecuteUpdateAsync. +/// +internal sealed class SqliteTestDbContext : AppDbContext +{ + public SqliteTestDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Replace NEWID() with a client-side Guid generator + modelBuilder.Entity() + .Property(s => s.Id) + .HasDefaultValueSql(null) + .HasValueGenerator(); + + modelBuilder.Entity() + .Property(c => c.Id) + .HasDefaultValueSql(null) + .HasValueGenerator(); + + // Remove GETUTCDATE() — C# property initializers always provide a value, + // so EF Core will include it in INSERT statements without a DB default. + modelBuilder.Entity() + .Property(s => s.CreatedAt) + .HasDefaultValueSql(null); + + modelBuilder.Entity() + .Property(c => c.ClickedAt) + .HasDefaultValueSql(null); + + modelBuilder.Entity() + .Property(u => u.CreatedAt) + .HasDefaultValueSql(null); + + modelBuilder.Entity() + .Property(b => b.CreatedAt) + .HasDefaultValueSql(null); + } +} diff --git a/src/SnipLink.Tests/UnitTest1.cs b/src/SnipLink.Tests/UnitTest1.cs deleted file mode 100644 index 3fabfea..0000000 --- a/src/SnipLink.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SnipLink.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file From 9d676a496f5bdb14308db1fe73e67e84784c0ef8 Mon Sep 17 00:00:00 2001 From: daniyal malik Date: Wed, 25 Jun 2025 12:00:00 +0200 Subject: [PATCH 6/9] feat: add Blazor UI auth flow, register page, and navigation --- .../20260330151219_InitialCreate.Designer.cs | 438 +++++++++++++++ .../20260330151219_InitialCreate.cs | 319 +++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 435 +++++++++++++++ src/SnipLink.Blazor/Components/App.razor | 5 +- .../Components/Layout/MainLayout.razor | 77 ++- .../Components/Layout/MainLayout.razor.css | 97 +--- .../Components/Layout/MinimalLayout.razor | 1 + .../Components/Pages/Dashboard.razor | 16 + .../Components/Pages/Login.razor | 99 ++-- .../Components/Pages/Register.razor | 127 +++++ src/SnipLink.Blazor/Components/_Imports.razor | 2 +- src/SnipLink.Blazor/Program.cs | 14 +- .../Services/SnipLinkApiClient.cs | 29 +- src/SnipLink.Blazor/wwwroot/app.css | 516 ++++++++++++++++-- 14 files changed, 1932 insertions(+), 243 deletions(-) create mode 100644 src/SnipLink.Api/Migrations/20260330151219_InitialCreate.Designer.cs create mode 100644 src/SnipLink.Api/Migrations/20260330151219_InitialCreate.cs create mode 100644 src/SnipLink.Api/Migrations/AppDbContextModelSnapshot.cs create mode 100644 src/SnipLink.Blazor/Components/Pages/Register.razor diff --git a/src/SnipLink.Api/Migrations/20260330151219_InitialCreate.Designer.cs b/src/SnipLink.Api/Migrations/20260330151219_InitialCreate.Designer.cs new file mode 100644 index 0000000..56de71b --- /dev/null +++ b/src/SnipLink.Api/Migrations/20260330151219_InitialCreate.Designer.cs @@ -0,0 +1,438 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SnipLink.Api.Data; + +#nullable disable + +namespace SnipLink.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260330151219_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.25"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.BlockedSlug", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Pattern") + .IsUnique() + .HasDatabaseName("IX_BlockedSlugs_Pattern"); + + b.ToTable("BlockedSlugs"); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ClickEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("NEWID()"); + + b.Property("ClickedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Country") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("IpHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Referrer") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ShortLinkId") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ShortLinkId", "ClickedAt") + .HasDatabaseName("IX_ClickEvents_ShortLinkId_ClickedAt"); + + b.ToTable("ClickEvents"); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("NEWID()"); + + b.Property("ClickCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0L); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("OriginalUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("IX_ShortLinks_Slug"); + + b.ToTable("ShortLinks"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ClickEvent", b => + { + b.HasOne("SnipLink.Api.Domain.ShortLink", "ShortLink") + .WithMany("Clicks") + .HasForeignKey("ShortLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ShortLink"); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => + { + b.HasOne("SnipLink.Api.Domain.ApplicationUser", "Owner") + .WithMany("Links") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ApplicationUser", b => + { + b.Navigation("Links"); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => + { + b.Navigation("Clicks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SnipLink.Api/Migrations/20260330151219_InitialCreate.cs b/src/SnipLink.Api/Migrations/20260330151219_InitialCreate.cs new file mode 100644 index 0000000..f3b9891 --- /dev/null +++ b/src/SnipLink.Api/Migrations/20260330151219_InitialCreate.cs @@ -0,0 +1,319 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SnipLink.Api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + DisplayName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "GETUTCDATE()"), + UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + Email = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "TEXT", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "INTEGER", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: true), + SecurityStamp = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", nullable: true), + PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), + TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), + LockoutEnd = table.Column(type: "TEXT", nullable: true), + LockoutEnabled = table.Column(type: "INTEGER", nullable: false), + AccessFailedCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "BlockedSlugs", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Pattern = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Reason = table.Column(type: "TEXT", maxLength: 500, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "GETUTCDATE()") + }, + constraints: table => + { + table.PrimaryKey("PK_BlockedSlugs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoleId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "TEXT", nullable: false), + ProviderKey = table.Column(type: "TEXT", nullable: false), + ProviderDisplayName = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ShortLinks", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false, defaultValueSql: "NEWID()"), + Slug = table.Column(type: "TEXT", maxLength: 50, nullable: false), + OriginalUrl = table.Column(type: "TEXT", maxLength: 2048, nullable: false), + Title = table.Column(type: "TEXT", maxLength: 256, nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + CreatedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "GETUTCDATE()"), + ExpiresAt = table.Column(type: "TEXT", nullable: true), + ClickCount = table.Column(type: "INTEGER", nullable: false, defaultValue: 0L), + OwnerId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ShortLinks", x => x.Id); + table.ForeignKey( + name: "FK_ShortLinks_AspNetUsers_OwnerId", + column: x => x.OwnerId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ClickEvents", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false, defaultValueSql: "NEWID()"), + ClickedAt = table.Column(type: "TEXT", nullable: false, defaultValueSql: "GETUTCDATE()"), + Referrer = table.Column(type: "TEXT", maxLength: 2048, nullable: true), + UserAgent = table.Column(type: "TEXT", maxLength: 512, nullable: true), + IpHash = table.Column(type: "TEXT", maxLength: 64, nullable: true), + Country = table.Column(type: "TEXT", maxLength: 2, nullable: true), + DeviceType = table.Column(type: "TEXT", maxLength: 10, nullable: false), + ShortLinkId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClickEvents", x => x.Id); + table.ForeignKey( + name: "FK_ClickEvents_ShortLinks_ShortLinkId", + column: x => x.ShortLinkId, + principalTable: "ShortLinks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BlockedSlugs_Pattern", + table: "BlockedSlugs", + column: "Pattern", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ClickEvents_ShortLinkId_ClickedAt", + table: "ClickEvents", + columns: new[] { "ShortLinkId", "ClickedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_ShortLinks_OwnerId", + table: "ShortLinks", + column: "OwnerId"); + + migrationBuilder.CreateIndex( + name: "IX_ShortLinks_Slug", + table: "ShortLinks", + column: "Slug", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "BlockedSlugs"); + + migrationBuilder.DropTable( + name: "ClickEvents"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "ShortLinks"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/src/SnipLink.Api/Migrations/AppDbContextModelSnapshot.cs b/src/SnipLink.Api/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..c66d7b3 --- /dev/null +++ b/src/SnipLink.Api/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,435 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SnipLink.Api.Data; + +#nullable disable + +namespace SnipLink.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.25"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.BlockedSlug", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Pattern") + .IsUnique() + .HasDatabaseName("IX_BlockedSlugs_Pattern"); + + b.ToTable("BlockedSlugs"); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ClickEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("NEWID()"); + + b.Property("ClickedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("Country") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("IpHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Referrer") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ShortLinkId") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ShortLinkId", "ClickedAt") + .HasDatabaseName("IX_ClickEvents_ShortLinkId_ClickedAt"); + + b.ToTable("ClickEvents"); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("NEWID()"); + + b.Property("ClickCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0L); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("GETUTCDATE()"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("OriginalUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("IX_ShortLinks_Slug"); + + b.ToTable("ShortLinks"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SnipLink.Api.Domain.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ClickEvent", b => + { + b.HasOne("SnipLink.Api.Domain.ShortLink", "ShortLink") + .WithMany("Clicks") + .HasForeignKey("ShortLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ShortLink"); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => + { + b.HasOne("SnipLink.Api.Domain.ApplicationUser", "Owner") + .WithMany("Links") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ApplicationUser", b => + { + b.Navigation("Links"); + }); + + modelBuilder.Entity("SnipLink.Api.Domain.ShortLink", b => + { + b.Navigation("Clicks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SnipLink.Blazor/Components/App.razor b/src/SnipLink.Blazor/Components/App.razor index 112c5fa..4a110ce 100644 --- a/src/SnipLink.Blazor/Components/App.razor +++ b/src/SnipLink.Blazor/Components/App.razor @@ -5,11 +5,14 @@ + + + - + diff --git a/src/SnipLink.Blazor/Components/Layout/MainLayout.razor b/src/SnipLink.Blazor/Components/Layout/MainLayout.razor index 326aa46..bbb2491 100644 --- a/src/SnipLink.Blazor/Components/Layout/MainLayout.razor +++ b/src/SnipLink.Blazor/Components/Layout/MainLayout.razor @@ -1,50 +1,45 @@ - @inherits LayoutComponentBase @inject AuthStateService AuthState @inject NavigationManager Nav - - -
-
- - SnipLink - - @if (AuthState.IsAuthenticated) - { - Dashboard - My Links - Create Link - } -
-
- @if (AuthState.IsAuthenticated) - { - @AuthState.CurrentUser!.Email - - } - else - { - - } -
-
-
+
+ SnipLink - -
- @Body -
-
+ @if (AuthState.IsAuthenticated) + { + + } + else + { +
+ } - -
- © @DateTime.Now.Year SnipLink — Short links, big impact. -
-
- +
+ @if (AuthState.IsAuthenticated) + { + @AuthState.CurrentUser!.Email + + } + else + { + + } +
+
+ +
+ @Body +
+ +
+ © @DateTime.Now.Year SnipLink — Short links, big impact. +
diff --git a/src/SnipLink.Blazor/Components/Layout/MainLayout.razor.css b/src/SnipLink.Blazor/Components/Layout/MainLayout.razor.css index 49baa0c..d9cc441 100644 --- a/src/SnipLink.Blazor/Components/Layout/MainLayout.razor.css +++ b/src/SnipLink.Blazor/Components/Layout/MainLayout.razor.css @@ -1,96 +1 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} - -#blazor-error-ui { - background: lightyellow; - bottom: 0; - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); - display: none; - left: 0; - padding: 0.6rem 1.25rem 0.7rem 1.25rem; - position: fixed; - width: 100%; - z-index: 1000; -} - - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } +/* MainLayout scoped styles — layout handled by app.css global classes */ diff --git a/src/SnipLink.Blazor/Components/Layout/MinimalLayout.razor b/src/SnipLink.Blazor/Components/Layout/MinimalLayout.razor index 5fda892..febc13b 100644 --- a/src/SnipLink.Blazor/Components/Layout/MinimalLayout.razor +++ b/src/SnipLink.Blazor/Components/Layout/MinimalLayout.razor @@ -3,6 +3,7 @@
SnipLink +

Short links, big impact.

@Body diff --git a/src/SnipLink.Blazor/Components/Pages/Dashboard.razor b/src/SnipLink.Blazor/Components/Pages/Dashboard.razor index 429972b..4f7e688 100644 --- a/src/SnipLink.Blazor/Components/Pages/Dashboard.razor +++ b/src/SnipLink.Blazor/Components/Pages/Dashboard.razor @@ -1,5 +1,6 @@ @page "/" @page "/dashboard" +@implements IDisposable @inject AuthStateService AuthState @inject SnipLinkApiClient ApiClient @inject NavigationManager Nav @@ -96,6 +97,8 @@ else protected override async Task OnInitializedAsync() { + AuthState.OnAuthStateChanged += OnAuthChanged; + if (!AuthState.IsAuthenticated) { Nav.NavigateTo("/login"); @@ -104,6 +107,14 @@ else await LoadDashboard(); } + private void OnAuthChanged() + { + if (!AuthState.IsAuthenticated) + Nav.NavigateTo("/login"); + else + InvokeAsync(StateHasChanged); + } + private async Task LoadDashboard() { isLoading = true; @@ -113,4 +124,9 @@ else private static string Truncate(string value, int max) => value.Length <= max ? value : value[..max] + "…"; + + public void Dispose() + { + AuthState.OnAuthStateChanged -= OnAuthChanged; + } } diff --git a/src/SnipLink.Blazor/Components/Pages/Login.razor b/src/SnipLink.Blazor/Components/Pages/Login.razor index f06c837..e8caea7 100644 --- a/src/SnipLink.Blazor/Components/Pages/Login.razor +++ b/src/SnipLink.Blazor/Components/Pages/Login.razor @@ -3,60 +3,74 @@ @inject AuthStateService AuthState @inject NavigationManager Nav -Login — SnipLink +Sign In — SnipLink @code { - private bool isRegister; private bool isLoading; + private bool showPassword; + private bool showNoAccountHint; private string? errorMessage; private readonly FormModel model = new(); @@ -66,41 +80,28 @@ Nav.NavigateTo("/dashboard"); } + private void TogglePassword() => showPassword = !showPassword; + private async Task HandleSubmit() { errorMessage = null; + showNoAccountHint = false; isLoading = true; + StateHasChanged(); try { - string? error; - - if (isRegister) + var error = await AuthState.LoginAsync(new LoginRequest { - if (string.IsNullOrWhiteSpace(model.DisplayName)) - { - errorMessage = "Display name is required."; - return; - } - error = await AuthState.RegisterAsync(new RegisterRequest - { - Email = model.Email, - Password = model.Password, - DisplayName = model.DisplayName - }); - } - else - { - error = await AuthState.LoginAsync(new LoginRequest - { - Email = model.Email, - Password = model.Password - }); - } + Email = model.Email, + Password = model.Password + }); if (error is not null) { errorMessage = error; + showNoAccountHint = error.Contains("Invalid email or password", StringComparison.OrdinalIgnoreCase); + StateHasChanged(); return; } @@ -109,6 +110,7 @@ finally { isLoading = false; + StateHasChanged(); } } @@ -116,6 +118,5 @@ { public string Email { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; } } diff --git a/src/SnipLink.Blazor/Components/Pages/Register.razor b/src/SnipLink.Blazor/Components/Pages/Register.razor new file mode 100644 index 0000000..93c7d83 --- /dev/null +++ b/src/SnipLink.Blazor/Components/Pages/Register.razor @@ -0,0 +1,127 @@ +@page "/register" +@layout SnipLink.Blazor.Components.Layout.MinimalLayout +@inject AuthStateService AuthState +@inject NavigationManager Nav + +Create Account — SnipLink + + + +@code { + private bool isLoading; + private bool showPassword; + private string? errorMessage; + private readonly FormModel model = new(); + + protected override void OnInitialized() + { + if (AuthState.IsAuthenticated) + Nav.NavigateTo("/dashboard"); + } + + private void TogglePassword() => showPassword = !showPassword; + + private async Task HandleSubmit() + { + errorMessage = null; + + if (string.IsNullOrWhiteSpace(model.DisplayName)) + { + errorMessage = "Display name is required."; + return; + } + + isLoading = true; + StateHasChanged(); + + try + { + var error = await AuthState.RegisterAsync(new RegisterRequest + { + Email = model.Email, + Password = model.Password, + DisplayName = model.DisplayName + }); + + if (error is not null) + { + errorMessage = error; + return; + } + + Nav.NavigateTo("/dashboard"); + } + finally + { + isLoading = false; + StateHasChanged(); + } + } + + private class FormModel + { + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + } +} diff --git a/src/SnipLink.Blazor/Components/_Imports.razor b/src/SnipLink.Blazor/Components/_Imports.razor index d2a1ea3..0de6391 100644 --- a/src/SnipLink.Blazor/Components/_Imports.razor +++ b/src/SnipLink.Blazor/Components/_Imports.razor @@ -4,7 +4,7 @@ @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using static Microsoft.AspNetCore.Components.Web.RenderMode -@rendermode InteractiveServer +@rendermode @(new InteractiveServerRenderMode(prerender: false)) @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using SnipLink.Blazor diff --git a/src/SnipLink.Blazor/Program.cs b/src/SnipLink.Blazor/Program.cs index 8d5814a..781d384 100644 --- a/src/SnipLink.Blazor/Program.cs +++ b/src/SnipLink.Blazor/Program.cs @@ -14,10 +14,18 @@ { client.BaseAddress = new Uri(apiBaseUrl); client.Timeout = TimeSpan.FromSeconds(30); -}).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler +}).ConfigurePrimaryHttpMessageHandler(() => { - AllowAutoRedirect = false, - UseCookies = false + var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false + }; + // Dev cert is self-signed and not trusted on Linux; bypass validation locally only. + if (builder.Environment.IsDevelopment()) + handler.ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + return handler; }); builder.Services.AddScoped(); diff --git a/src/SnipLink.Blazor/Services/SnipLinkApiClient.cs b/src/SnipLink.Blazor/Services/SnipLinkApiClient.cs index b8d270a..416223c 100644 --- a/src/SnipLink.Blazor/Services/SnipLinkApiClient.cs +++ b/src/SnipLink.Blazor/Services/SnipLinkApiClient.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; using SnipLink.Shared.DTOs; using SnipLink.Shared.DTOs.Analytics; @@ -57,7 +58,7 @@ private void CaptureCookies(HttpResponseMessage response) catch { return (null, "Could not reach the server."); } if (!resp.IsSuccessStatusCode) - return (null, await ReadErrorAsync(resp) ?? "Registration failed."); + return (null, await ReadIdentityErrorAsync(resp) ?? "Registration failed."); CaptureCookies(resp); return (await resp.Content.ReadFromJsonAsync(), null); @@ -226,4 +227,30 @@ public async Task DeleteLinkAsync(Guid id) } catch { return null; } } + + /// Parses ASP.NET Identity's {"errors":["..."]} BadRequest format. + private static async Task ReadIdentityErrorAsync(HttpResponseMessage response) + { + try + { + var body = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(body)) return null; + + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("errors", out var errorsEl) && + errorsEl.ValueKind == JsonValueKind.Array) + { + var messages = errorsEl.EnumerateArray() + .Select(e => e.GetString()) + .Where(s => s is not null) + .ToList(); + + if (messages.Count > 0) + return string.Join(" ", messages); + } + + return body; + } + catch { return null; } + } } diff --git a/src/SnipLink.Blazor/wwwroot/app.css b/src/SnipLink.Blazor/wwwroot/app.css index 7ac30e2..5d90d93 100644 --- a/src/SnipLink.Blazor/wwwroot/app.css +++ b/src/SnipLink.Blazor/wwwroot/app.css @@ -1,51 +1,465 @@ -html, body { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -} - -a, .btn-link { - color: #006bb7; -} - -.btn-primary { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; -} - -.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { - box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; -} - -.content { - padding-top: 1.1rem; -} - -h1:focus { - outline: none; -} - -.valid.modified:not([type=checkbox]) { - outline: 1px solid #26b050; -} - -.invalid { - outline: 1px solid #e50000; -} - -.validation-message { - color: #e50000; -} - -.blazor-error-boundary { - background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; - padding: 1rem 1rem 1rem 3.7rem; - color: white; -} - - .blazor-error-boundary::after { - content: "An error has occurred." - } - -.darker-border-checkbox.form-check-input { - border-color: #929292; -} +/* ============================================ + CSS Custom Properties + ============================================ */ +:root { + --primary: #4f46e5; + --primary-hover: #4338ca; + --primary-light: #eef2ff; + --primary-dark: #3730a3; + --success: #10b981; + --success-light: #d1fae5; + --danger: #ef4444; + --danger-light: #fee2e2; + --warning: #f59e0b; + --text-primary: #0f172a; + --text-secondary: #64748b; + --text-muted: #94a3b8; + --bg-app: #f1f5f9; + --bg-card: #ffffff; + --border-color: #e2e8f0; + --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --radius-sm: 0.375rem; + --radius: 0.625rem; + --radius-lg: 0.875rem; + --radius-xl: 1.25rem; +} + +/* ============================================ + Base + ============================================ */ +*, *::before, *::after { + box-sizing: border-box; +} + +html, body { + font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif; + background-color: var(--bg-app); + color: var(--text-primary); + margin: 0; + padding: 0; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-size: 15px; + line-height: 1.6; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-primary); + line-height: 1.3; +} + +h2 { font-size: 1.55rem; } +h3 { font-size: 1.25rem; } +h4 { font-size: 1.05rem; } + +a { + color: var(--primary); + text-decoration: none; +} + +a:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +h1:focus { + outline: none; +} + +/* ============================================ + App Shell Header + ============================================ */ +.snip-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 62px; + padding: 0 1.75rem; + background: #ffffff; + border-bottom: 1px solid var(--border-color); + box-shadow: var(--shadow-xs); + position: sticky; + top: 0; + z-index: 200; + gap: 1rem; +} + +.snip-brand { + font-size: 1.3rem; + font-weight: 800; + color: var(--primary); + letter-spacing: -0.05em; + text-decoration: none; + white-space: nowrap; + flex-shrink: 0; +} + +.snip-brand:hover { + color: var(--primary-hover); + text-decoration: none; +} + +.snip-brand-suffix { + color: var(--text-primary); +} + +.snip-nav { + display: flex; + align-items: center; + gap: 0.125rem; + flex: 1; + padding: 0 1rem; +} + +.snip-nav-link { + padding: 0.4rem 0.875rem; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + text-decoration: none; + transition: background 0.15s ease, color 0.15s ease; + white-space: nowrap; +} + +.snip-nav-link:hover { + background-color: var(--bg-app); + color: var(--text-primary); + text-decoration: none; +} + +.snip-nav-link.active { + background-color: var(--primary-light); + color: var(--primary); + font-weight: 600; +} + +.snip-header-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + +.snip-user-email { + font-size: 0.8rem; + color: var(--text-secondary); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ============================================ + Main Body + ============================================ */ +.snip-page-body { + min-height: calc(100vh - 62px - 48px); + padding: 2rem 2rem; + max-width: 1280px; + margin: 0 auto; + width: 100%; +} + +/* ============================================ + Footer + ============================================ */ +.snip-footer { + text-align: center; + padding: 0.875rem 1.5rem; + color: var(--text-muted); + font-size: 0.8rem; + border-top: 1px solid var(--border-color); + background: white; +} + +/* ============================================ + Cards — override Radzen defaults + ============================================ */ +.rz-card { + border-radius: var(--radius-lg) !important; + box-shadow: var(--shadow-sm) !important; + border: 1px solid var(--border-color) !important; + background: var(--bg-card) !important; +} + +/* ============================================ + Stat Cards (Dashboard + Analytics) + ============================================ */ +.stat-card { + text-align: center; + padding: 1.75rem 1.25rem !important; + border-radius: var(--radius-lg) !important; + background: white !important; + border: 1px solid var(--border-color) !important; + box-shadow: var(--shadow-sm) !important; + transition: box-shadow 0.2s, transform 0.2s; +} + +.stat-card:hover { + box-shadow: var(--shadow-md) !important; + transform: translateY(-1px); +} + +.stat-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-muted); + margin: 0 0 0.5rem 0; +} + +.stat-value { + font-size: 2.4rem; + font-weight: 800; + color: var(--primary); + margin: 0; + letter-spacing: -0.04em; + line-height: 1; +} + +/* ============================================ + Auth / Minimal Layout + ============================================ */ +.minimal-layout { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: linear-gradient(145deg, #eff6ff 0%, #f5f3ff 45%, #fdf4ff 100%); + padding: 2rem 1rem; +} + +.minimal-brand { + margin-bottom: 2rem; + text-align: center; +} + +.minimal-brand a { + font-size: 1.8rem; + font-weight: 800; + color: var(--primary); + letter-spacing: -0.06em; + text-decoration: none; +} + +.minimal-brand a:hover { + color: var(--primary-hover); + text-decoration: none; +} + +.minimal-body { + width: 100%; + max-width: 430px; +} + +/* ============================================ + Login / Register Card + ============================================ */ +.login-container { + width: 100%; +} + +.login-card { + border-radius: var(--radius-xl) !important; + box-shadow: var(--shadow-xl) !important; + border: 1px solid var(--border-color) !important; + padding: 2.25rem 2rem !important; + background: white !important; +} + +.login-card h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1.5rem !important; + color: var(--text-primary); +} + +.auth-footer-text { + text-align: center; + font-size: 0.875rem; + color: var(--text-secondary); + margin-top: 0.75rem; +} + +.auth-toggle-link { + color: var(--primary); + font-weight: 600; + cursor: pointer; + text-decoration: none; + background: none; + border: none; + padding: 0; + font-size: inherit; + font-family: inherit; +} + +.auth-toggle-link:hover { + text-decoration: underline; + color: var(--primary-hover); +} + +.field-label { + display: block; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.35rem; +} + +.password-wrapper { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.password-wrapper .password-input { + width: 100% !important; + padding-right: 2.75rem !important; +} + +.eye-toggle { + position: absolute; + right: 0.65rem; + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--text-secondary); + display: flex; + align-items: center; + line-height: 1; + z-index: 2; +} + +.eye-toggle:hover { + color: var(--text-primary); +} + +/* ============================================ + Success card (Create Link page) + ============================================ */ +.success-card { + border-left: 4px solid var(--success) !important; + background: #f0fdf4 !important; +} + +.short-url-display { + font-size: 1rem; + font-weight: 600; + word-break: break-all; +} + +/* ============================================ + Blazor Error UI + ============================================ */ +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + +.blazor-error-boundary::after { + content: "An error has occurred." +} + +#blazor-error-ui { + background: var(--danger-light); + border-top: 2px solid var(--danger); + bottom: 0; + box-shadow: var(--shadow-lg); + display: none; + left: 0; + padding: 0.75rem 1.5rem; + position: fixed; + width: 100%; + z-index: 1000; + color: #991b1b; + font-size: 0.875rem; +} + +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + font-size: 1.25rem; + line-height: 1; +} + +/* ============================================ + Validation States + ============================================ */ +.valid.modified:not([type=checkbox]) { + outline: 1px solid var(--success); +} + +.invalid { + outline: 1px solid var(--danger); +} + +.validation-message { + color: var(--danger); + font-size: 0.8rem; + margin-top: 0.25rem; +} + +/* ============================================ + Buttons + ============================================ */ +.btn-primary { + color: #fff; + background-color: var(--primary); + border-color: var(--primary); +} + +.btn-primary:hover { + background-color: var(--primary-hover); + border-color: var(--primary-hover); +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #818cf8; +} + +/* ============================================ + Misc utilities + ============================================ */ +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +/* ============================================ + Responsive + ============================================ */ +@media (max-width: 768px) { + .snip-header { + padding: 0 1rem; + } + + .snip-page-body { + padding: 1.25rem 1rem; + } + + .snip-nav { + display: none; + } + + .snip-user-email { + display: none; + } + + .stat-value { + font-size: 1.75rem; + } +} From 4beac67b50ac5840ef03b7743639b3112e99848d Mon Sep 17 00:00:00 2001 From: daniyal malik Date: Sat, 5 Jul 2025 12:00:00 +0200 Subject: [PATCH 7/9] feat: add email verification flow and dual email provider support - Add email verification on register (token sent via SMTP or Resend) - Add GET /api/auth/verify-email endpoint to confirm tokens - Add POST /api/auth/resend-verification endpoint - Add IEmailService abstraction with SmtpEmailService and ResendEmailService - Auto-select Resend if ApiKey is configured, otherwise fall back to SMTP - Add VerifyEmail.razor page and update Register/Login Blazor pages - Add ResendVerificationRequest DTO and EmailOptions/ResendOptions - Add appsettings.Local.json to .gitignore and provide .example file --- .gitignore | 1 + .../Controllers/AuthController.cs | 109 ++++++++++- src/SnipLink.Api/Options/EmailOptions.cs | 14 ++ src/SnipLink.Api/Options/ResendOptions.cs | 7 + src/SnipLink.Api/Program.cs | 28 ++- src/SnipLink.Api/Services/IEmailService.cs | 6 + .../Services/ResendEmailService.cs | 62 +++++++ src/SnipLink.Api/Services/SmtpEmailService.cs | 51 ++++++ src/SnipLink.Api/SnipLink.Api.csproj | 52 +++--- .../appsettings.Local.json.example | 11 ++ src/SnipLink.Api/appsettings.json | 15 ++ src/SnipLink.Blazor/Components/App.razor | 2 +- .../Components/Pages/Login.razor | 63 ++++++- .../Components/Pages/Register.razor | 169 ++++++++++++------ .../Components/Pages/VerifyEmail.razor | 60 +++++++ .../Services/AuthStateService.cs | 7 +- .../Services/SnipLinkApiClient.cs | 54 +++++- .../DTOs/ResendVerificationRequest.cs | 11 ++ 18 files changed, 626 insertions(+), 96 deletions(-) create mode 100644 src/SnipLink.Api/Options/EmailOptions.cs create mode 100644 src/SnipLink.Api/Options/ResendOptions.cs create mode 100644 src/SnipLink.Api/Services/IEmailService.cs create mode 100644 src/SnipLink.Api/Services/ResendEmailService.cs create mode 100644 src/SnipLink.Api/Services/SmtpEmailService.cs create mode 100644 src/SnipLink.Api/appsettings.Local.json.example create mode 100644 src/SnipLink.Blazor/Components/Pages/VerifyEmail.razor create mode 100644 src/SnipLink.Shared/DTOs/ResendVerificationRequest.cs diff --git a/.gitignore b/.gitignore index f98ef0f..70c96ce 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ ScaffoldingReadMe.txt appsettings.Development.json appsettings.Production.json appsettings.Staging.json +appsettings.Local.json **/secrets.json **/*.pfx **/*.p12 diff --git a/src/SnipLink.Api/Controllers/AuthController.cs b/src/SnipLink.Api/Controllers/AuthController.cs index 04dc1fe..3052475 100644 --- a/src/SnipLink.Api/Controllers/AuthController.cs +++ b/src/SnipLink.Api/Controllers/AuthController.cs @@ -1,8 +1,11 @@ using System.Security.Claims; +using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; using SnipLink.Api.Domain; +using SnipLink.Api.Services; using SnipLink.Shared.DTOs; namespace SnipLink.Api.Controllers; @@ -13,13 +16,22 @@ public sealed class AuthController : ControllerBase { private readonly UserManager _userManager; private readonly SignInManager _signIn; + private readonly IEmailService _email; + private readonly IConfiguration _config; + private readonly ILogger _logger; public AuthController( UserManager userManager, - SignInManager signIn) + SignInManager signIn, + IEmailService email, + IConfiguration config, + ILogger logger) { _userManager = userManager; - _signIn = signIn; + _signIn = signIn; + _email = email; + _config = config; + _logger = logger; } // ── Register ────────────────────────────────────────────────────────────── @@ -42,9 +54,53 @@ public async Task Register([FromBody] RegisterRequest request) return BadRequest(new { errors }); } - await _signIn.SignInAsync(user, isPersistent: false); + await SendVerificationEmailAsync(user); - return Ok(BuildAuthResponse(user)); + return Ok(new { message = "Registration successful. Please check your email to verify your account." }); + } + + // ── Verify email ────────────────────────────────────────────────────────── + + [HttpGet("verify-email")] + public async Task VerifyEmail([FromQuery] string userId, [FromQuery] string token) + { + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(token)) + return BadRequest(new { error = "Invalid verification link." }); + + var user = await _userManager.FindByIdAsync(userId); + if (user is null) + return BadRequest(new { error = "Invalid verification link." }); + + string decodedToken; + try + { + decodedToken = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)); + } + catch + { + return BadRequest(new { error = "Invalid verification link." }); + } + + var result = await _userManager.ConfirmEmailAsync(user, decodedToken); + if (!result.Succeeded) + return BadRequest(new { error = "Email verification failed. The link may have expired." }); + + return Ok(new { message = "Email verified successfully. You can now sign in." }); + } + + // ── Resend verification email ───────────────────────────────────────────── + + [HttpPost("resend-verification")] + public async Task ResendVerification([FromBody] ResendVerificationRequest request) + { + // Always return 200 to prevent user enumeration + var user = await _userManager.FindByEmailAsync(request.Email); + if (user is null || await _userManager.IsEmailConfirmedAsync(user)) + return Ok(new { message = "If that address is registered and unverified, a new email has been sent." }); + + await SendVerificationEmailAsync(user); + + return Ok(new { message = "If that address is registered and unverified, a new email has been sent." }); } // ── Login ───────────────────────────────────────────────────────────────── @@ -62,6 +118,9 @@ public async Task Login([FromBody] LoginRequest request) return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "Account is temporarily locked. Please try again later." }); + if (result.IsNotAllowed) + return Unauthorized(new { error = "Please verify your email address before signing in." }); + if (!result.Succeeded) return Unauthorized(new { error = "Invalid email or password." }); @@ -94,7 +153,47 @@ public async Task Me() return Ok(BuildAuthResponse(user).User); } - // ── Helper ──────────────────────────────────────────────────────────────── + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task SendVerificationEmailAsync(ApplicationUser user) + { + var rawToken = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var encodedToken = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(rawToken)); + var frontendBase = _config["Frontend:BaseUrl"] ?? "https://localhost:7129"; + var verifyUrl = $"{frontendBase}/verify-email?userId={user.Id}&token={encodedToken}"; + + var html = $""" +
+

Welcome to SnipLink, {user.DisplayName}!

+

Thanks for registering. Please verify your email address by clicking the button below.

+ + Verify Email Address + +

+ If the button doesn't work, copy and paste this link into your browser:
+ {verifyUrl} +

+

+ If you didn't create an account, you can safely ignore this email. +

+
+ """; + + try + { + await _email.SendAsync(user.Email!, "Verify your SnipLink account", html); + } + catch (Exception ex) + { + // Don't fail registration if email delivery fails — log the verification link + // so it's accessible during development. + _logger.LogError(ex, + "Failed to send verification email to {Email}. Verification URL: {Url}", + user.Email, verifyUrl); + } + } private static AuthResponse BuildAuthResponse(ApplicationUser user) => new() { diff --git a/src/SnipLink.Api/Options/EmailOptions.cs b/src/SnipLink.Api/Options/EmailOptions.cs new file mode 100644 index 0000000..9949e55 --- /dev/null +++ b/src/SnipLink.Api/Options/EmailOptions.cs @@ -0,0 +1,14 @@ +namespace SnipLink.Api.Options; + +public sealed class EmailOptions +{ + public const string SectionName = "Email"; + + public string From { get; init; } = string.Empty; + public string FromName { get; init; } = "SnipLink"; + public string Host { get; init; } = string.Empty; + public int Port { get; init; } = 587; + public bool UseSsl { get; init; } + public string Username { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; +} diff --git a/src/SnipLink.Api/Options/ResendOptions.cs b/src/SnipLink.Api/Options/ResendOptions.cs new file mode 100644 index 0000000..97759c0 --- /dev/null +++ b/src/SnipLink.Api/Options/ResendOptions.cs @@ -0,0 +1,7 @@ +namespace SnipLink.Api.Options; + +public sealed class ResendOptions +{ + public const string SectionName = "Resend"; + public string ApiKey { get; init; } = string.Empty; +} diff --git a/src/SnipLink.Api/Program.cs b/src/SnipLink.Api/Program.cs index ead6bdb..ea3630e 100644 --- a/src/SnipLink.Api/Program.cs +++ b/src/SnipLink.Api/Program.cs @@ -2,13 +2,17 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using SnipLink.Api.Data; using SnipLink.Api.Domain; using SnipLink.Api.Middleware; +using SnipLink.Api.Options; using SnipLink.Api.Services; var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); + // ── Database ────────────────────────────────────────────────────────────────── builder.Services.AddDbContext(options => options.UseSqlite( @@ -31,7 +35,8 @@ options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); options.Lockout.AllowedForNewUsers = true; - options.User.RequireUniqueEmail = true; + options.User.RequireUniqueEmail = true; + options.SignIn.RequireConfirmedEmail = true; }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); @@ -114,6 +119,27 @@ await ctx.HttpContext.Response.WriteAsync( .AllowAnyMethod() .AllowCredentials())); +// ── Email ───────────────────────────────────────────────────────────────────── +builder.Services.Configure( + builder.Configuration.GetSection(EmailOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(ResendOptions.SectionName)); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(sp => +{ + var resendOpts = sp.GetRequiredService>().Value; + if (!string.IsNullOrWhiteSpace(resendOpts.ApiKey)) + return new ResendEmailService( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService().CreateClient(), + sp.GetRequiredService>()); + + return new SmtpEmailService( + sp.GetRequiredService>(), + sp.GetRequiredService>()); +}); + // ── Application services ────────────────────────────────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/SnipLink.Api/Services/IEmailService.cs b/src/SnipLink.Api/Services/IEmailService.cs new file mode 100644 index 0000000..989010f --- /dev/null +++ b/src/SnipLink.Api/Services/IEmailService.cs @@ -0,0 +1,6 @@ +namespace SnipLink.Api.Services; + +public interface IEmailService +{ + Task SendAsync(string to, string subject, string htmlBody); +} diff --git a/src/SnipLink.Api/Services/ResendEmailService.cs b/src/SnipLink.Api/Services/ResendEmailService.cs new file mode 100644 index 0000000..1fa30ef --- /dev/null +++ b/src/SnipLink.Api/Services/ResendEmailService.cs @@ -0,0 +1,62 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using SnipLink.Api.Options; + +namespace SnipLink.Api.Services; + +public sealed class ResendEmailService : IEmailService +{ + private readonly ResendOptions _resend; + private readonly EmailOptions _email; + private readonly HttpClient _http; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions _json = + new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private record ResendRequest(string From, string[] To, string Subject, string Html); + + public ResendEmailService( + IOptions resend, + IOptions email, + HttpClient http, + ILogger logger) + { + _resend = resend.Value; + _email = email.Value; + _http = http; + _logger = logger; + } + + public async Task SendAsync(string to, string subject, string htmlBody) + { + var from = string.IsNullOrWhiteSpace(_email.FromName) + ? _email.From + : $"{_email.FromName} <{_email.From}>"; + + var payload = new ResendRequest(from, [to], subject, htmlBody); + var json = JsonSerializer.Serialize(payload, _json); + + using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.resend.com/emails"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _resend.ApiKey); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _http.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + "Resend API error {Status} sending to {To}: {Body}", + (int)response.StatusCode, to, body); + response.EnsureSuccessStatusCode(); + } + + using var doc = JsonDocument.Parse(body); + var messageId = doc.RootElement.TryGetProperty("id", out var id) ? id.GetString() : "?"; + _logger.LogInformation( + "Email sent via Resend to {To}: {Subject} (id: {MessageId})", to, subject, messageId); + } +} diff --git a/src/SnipLink.Api/Services/SmtpEmailService.cs b/src/SnipLink.Api/Services/SmtpEmailService.cs new file mode 100644 index 0000000..198c70a --- /dev/null +++ b/src/SnipLink.Api/Services/SmtpEmailService.cs @@ -0,0 +1,51 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; +using SnipLink.Api.Options; + +namespace SnipLink.Api.Services; + +public sealed class SmtpEmailService : IEmailService +{ + private readonly EmailOptions _options; + private readonly ILogger _logger; + + public SmtpEmailService(IOptions options, ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public async Task SendAsync(string to, string subject, string htmlBody) + { + // In development or when no SMTP host is configured, log to console instead of sending. + if (string.IsNullOrWhiteSpace(_options.Host)) + { + _logger.LogInformation( + "[EMAIL - no SMTP configured]\nTo: {To}\nSubject: {Subject}\nBody:\n{Body}", + to, subject, htmlBody); + return; + } + + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(_options.FromName, _options.From)); + message.To.Add(new MailboxAddress(string.Empty, to)); + message.Subject = subject; + message.Body = new TextPart("html") { Text = htmlBody }; + + using var client = new SmtpClient(); + await client.ConnectAsync( + _options.Host, + _options.Port, + _options.UseSsl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTlsWhenAvailable); + + if (!string.IsNullOrWhiteSpace(_options.Username)) + await client.AuthenticateAsync(_options.Username, _options.Password); + + await client.SendAsync(message); + await client.DisconnectAsync(quit: true); + + _logger.LogInformation("Email sent to {To}: {Subject}", to, subject); + } +} diff --git a/src/SnipLink.Api/SnipLink.Api.csproj b/src/SnipLink.Api/SnipLink.Api.csproj index 1671265..77106b2 100644 --- a/src/SnipLink.Api/SnipLink.Api.csproj +++ b/src/SnipLink.Api/SnipLink.Api.csproj @@ -1,25 +1,27 @@ - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - net8.0 - enable - enable - - - + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + net8.0 + enable + enable + 303c8f4a-968d-45a2-8471-022f437d803e + + + diff --git a/src/SnipLink.Api/appsettings.Local.json.example b/src/SnipLink.Api/appsettings.Local.json.example new file mode 100644 index 0000000..d583039 --- /dev/null +++ b/src/SnipLink.Api/appsettings.Local.json.example @@ -0,0 +1,11 @@ +{ + "Resend": { + "ApiKey": "re_YOUR_ACTUAL_KEY_HERE" + }, + "Email": { + "From": "noreply@yourdomain.com" + }, + "Frontend": { + "BaseUrl": "https://localhost:7129" + } +} diff --git a/src/SnipLink.Api/appsettings.json b/src/SnipLink.Api/appsettings.json index 4804314..23dc592 100644 --- a/src/SnipLink.Api/appsettings.json +++ b/src/SnipLink.Api/appsettings.json @@ -14,5 +14,20 @@ }, "Cors": { "AllowedOrigin": "https://localhost:7129" + }, + "Frontend": { + "BaseUrl": "https://localhost:7129" + }, + "Email": { + "From": "noreply@sniplink.app", + "FromName": "SnipLink", + "Host": "", + "Port": 587, + "UseSsl": false, + "Username": "", + "Password": "" + }, + "Resend": { + "ApiKey": "" } } diff --git a/src/SnipLink.Blazor/Components/App.razor b/src/SnipLink.Blazor/Components/App.razor index 4a110ce..047ce9d 100644 --- a/src/SnipLink.Blazor/Components/App.razor +++ b/src/SnipLink.Blazor/Components/App.razor @@ -16,7 +16,7 @@ - + diff --git a/src/SnipLink.Blazor/Components/Pages/Login.razor b/src/SnipLink.Blazor/Components/Pages/Login.razor index e8caea7..1dbee66 100644 --- a/src/SnipLink.Blazor/Components/Pages/Login.razor +++ b/src/SnipLink.Blazor/Components/Pages/Login.razor @@ -2,6 +2,7 @@ @layout SnipLink.Blazor.Components.Layout.MinimalLayout @inject AuthStateService AuthState @inject NavigationManager Nav +@inject SnipLinkApiClient Api Sign In — SnipLink @@ -20,6 +21,31 @@ } + @if (showUnverifiedBanner) + { + + Your email is not verified yet. + @if (resendLoading) + { + Sending... + } + else if (resendSent) + { + Verification email resent — check your inbox. + } + else + { + Click here to resend the verification email. + } + + @if (resendError is not null) + { + + @resendError + + } + } + @@ -71,6 +97,10 @@ private bool isLoading; private bool showPassword; private bool showNoAccountHint; + private bool showUnverifiedBanner; + private bool resendLoading; + private bool resendSent; + private string? resendError; private string? errorMessage; private readonly FormModel model = new(); @@ -82,10 +112,32 @@ private void TogglePassword() => showPassword = !showPassword; + private async Task HandleResend() + { + resendError = null; + resendSent = false; + resendLoading = true; + StateHasChanged(); + try + { + var err = await Api.ResendVerificationAsync(model.Email); + if (err is not null) resendError = err; + else resendSent = true; + } + finally + { + resendLoading = false; + StateHasChanged(); + } + } + private async Task HandleSubmit() { errorMessage = null; showNoAccountHint = false; + showUnverifiedBanner = false; + resendSent = false; + resendError = null; isLoading = true; StateHasChanged(); @@ -99,8 +151,15 @@ if (error is not null) { - errorMessage = error; - showNoAccountHint = error.Contains("Invalid email or password", StringComparison.OrdinalIgnoreCase); + if (error.Contains("verify your email", StringComparison.OrdinalIgnoreCase)) + { + showUnverifiedBanner = true; + } + else + { + errorMessage = error; + showNoAccountHint = error.Contains("Invalid email or password", StringComparison.OrdinalIgnoreCase); + } StateHasChanged(); return; } diff --git a/src/SnipLink.Blazor/Components/Pages/Register.razor b/src/SnipLink.Blazor/Components/Pages/Register.razor index 93c7d83..ecd7475 100644 --- a/src/SnipLink.Blazor/Components/Pages/Register.razor +++ b/src/SnipLink.Blazor/Components/Pages/Register.razor @@ -2,6 +2,7 @@ @layout SnipLink.Blazor.Components.Layout.MinimalLayout @inject AuthStateService AuthState @inject NavigationManager Nav +@inject SnipLinkApiClient Api Create Account — SnipLink @@ -9,68 +10,111 @@