From 5367a56113ef2b4d2b727c309afbeee9ecca333a Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 25 Sep 2024 08:48:19 -0700 Subject: [PATCH 01/10] Initial service --- Directory.Packages.props | 3 +- .../EssentialCSharp.Web.csproj | 1 + EssentialCSharp.Web/Program.cs | 13 ++++-- .../Services/ReferrerService.cs | 43 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 EssentialCSharp.Web/Services/ReferrerService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 95b2f353..f774e5c0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,7 +16,8 @@ - + + diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index 398bd245..bb230719 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -33,6 +33,7 @@ + diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 6cfbd3ce..babe8f8a 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -8,7 +8,8 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Sqids; namespace EssentialCSharp.Web; @@ -104,8 +105,14 @@ private static void Main(string[] args) builder.Services.AddRazorPages(); builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - + builder.Services.AddHostedService(); + builder.Services.AddSingleton(new SqidsEncoder(new() + { + // This is a shuffled version of the default alphabet so the id's are at least unique to this site. + // This being open source, it will be easy to decode the ids, but these id's are not meant to be secure. + Alphabet = "imx4BSz2Ys7GZLXDqT5IAkUOEnyvwbPKJtp13NWdeuH6rFfRhCcQogjaM8V09l", + MinLength = 10, + })); if (!builder.Environment.IsDevelopment()) { diff --git a/EssentialCSharp.Web/Services/ReferrerService.cs b/EssentialCSharp.Web/Services/ReferrerService.cs new file mode 100644 index 00000000..010d372d --- /dev/null +++ b/EssentialCSharp.Web/Services/ReferrerService.cs @@ -0,0 +1,43 @@ +using Sqids; + +namespace EssentialCSharp.Web.Services; + +public class ReferrerService(SqidsEncoder sqids) +{ + public string GenerateReferrerLink(string baseUrl, string userId) + { + string referrerId = sqids.Encode(1, 2, 3); + string referrerLink = $"{baseUrl}?referrerId={referrerId}"; + + // Store the referrerId and userId in the database for tracking + SaveReferrerIdToDatabase(userId, referrerId); + + return referrerLink; + } + + private void SaveReferrerIdToDatabase(string userId, string referrerId) + { + // Implement your database logic here + } + + /// + /// Track the referral in the database. + /// + /// The referrer ID to track. + /// True if the referral was successfully tracked, otherwise false. + public bool TrackReferral(string referrerId) + { + // Implement your logic to track the referral in the database + + if (sqids.Decode(referrerId) is [var decodedId] && + referrerId == sqids.Encode(decodedId)) + { + // `incomingId` decodes into a single number and is canonical, here you can safely proceed with the rest of the logic + } + else + { + // consider `incomingId` invalid — e.g. respond with 404 + } + IReadOnlyList numbers = sqids.Decode(referrerId); // [1, 2, 3] + } +} From 01ad95defe8ee0e9270ce46e0a8a76b6494257e6 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 13 Feb 2025 13:22:00 -0800 Subject: [PATCH 02/10] WIP on Referral Service --- Directory.Packages.props | 7 +- .../Identity/Data/EssentialCSharpWebUser.cs | 2 + .../EssentialCSharp.Web.csproj | 6 +- .../Middleware/ReferralTrackingMiddleware.cs | 78 ++++++++++++++++++ EssentialCSharp.Web/Program.cs | 22 +++--- .../Services/IReferralService.cs | 9 +++ .../Services/ReferralService.cs | 79 +++++++++++++++++++ .../Services/ReferrerService.cs | 43 ---------- .../Views/Shared/_Layout.cshtml | 4 +- 9 files changed, 190 insertions(+), 60 deletions(-) create mode 100644 EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs create mode 100644 EssentialCSharp.Web/Services/IReferralService.cs create mode 100644 EssentialCSharp.Web/Services/ReferralService.cs delete mode 100644 EssentialCSharp.Web/Services/ReferrerService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f774e5c0..ae9d0e58 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,12 +16,10 @@ - - + - @@ -32,6 +30,8 @@ + + @@ -39,6 +39,7 @@ + diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs index ff5e62f3..d4119927 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs @@ -9,5 +9,7 @@ public class EssentialCSharpWebUser : IdentityUser public virtual string? FirstName { get; set; } [ProtectedPersonalData] public virtual string? LastName { get; set; } + public string? ReferrerId { get; set; } + public int ReferralCount { get; set; } } diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index bb230719..1653a4a4 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -14,22 +14,24 @@ + + - + + runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs new file mode 100644 index 00000000..2ede6c99 --- /dev/null +++ b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs @@ -0,0 +1,78 @@ +using System.Security.Claims; +using System.Web; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Identity; + +namespace EssentialCSharp.Web.Middleware; + +public class ReferralMiddleware +{ + private readonly RequestDelegate _Next; + + public ReferralMiddleware(RequestDelegate next) + { + _Next = next; + } + + public async Task InvokeAsync(HttpContext context, IReferralService referralService, UserManager userManager) + { + // Retrieve current referral Id for processing + System.Collections.Specialized.NameValueCollection query = HttpUtility.ParseQueryString(context.Request.QueryString.Value!); + string? referralId = query["rid"]; + string? userReferralId; + + if (context.User is { } claimsUser && claimsUser.Identity is not null && claimsUser.Identity.IsAuthenticated) + { + if (!string.IsNullOrWhiteSpace(referralId)) + { + await TrackReferralAsync(referralService, referralId, claimsUser); + } + + // Add the referralId to the request context if it exists on a user + EssentialCSharpWebUser? user = await userManager.GetUserAsync(claimsUser); + if (user is not null) + { + userReferralId = await referralService.GetReferralIdAsync(user.Id); + + if (!string.IsNullOrWhiteSpace(userReferralId) && (string.IsNullOrWhiteSpace(query["rid"]) || query["rid"] != userReferralId)) + { + query.Remove("rid"); + query.Add("rid", userReferralId); + var builder = new UriBuilder(context.Request.GetEncodedUrl()) + { + Query = query.ToString() + }; + context.Response.Redirect(builder.ToString()); + return; + } + } + } + else + { + + if (!string.IsNullOrWhiteSpace(referralId)) + { + await TrackReferralAsync(referralService, referralId, null); + query.Remove("rid"); + var builder = new UriBuilder(context.Request.GetEncodedUrl()) + { + Query = query.ToString() + }; + context.Response.Redirect(builder.ToString()); + return; + } + } + + await _Next(context); + + static async Task TrackReferralAsync(IReferralService referralService, string? referralId, ClaimsPrincipal? claimsUser) + { + if (!string.IsNullOrWhiteSpace(referralId)) + { + _ = await referralService.TrackReferralAsync(referralId, claimsUser); + } + } + } +} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index babe8f8a..8ced257d 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -110,9 +110,10 @@ private static void Main(string[] args) { // This is a shuffled version of the default alphabet so the id's are at least unique to this site. // This being open source, it will be easy to decode the ids, but these id's are not meant to be secure. - Alphabet = "imx4BSz2Ys7GZLXDqT5IAkUOEnyvwbPKJtp13NWdeuH6rFfRhCcQogjaM8V09l", - MinLength = 10, - })); + Alphabet = "imx4z2Ys7GZLXDqT5IkUOEnyvwPKJtp13NWdeuH6rRhCcQogjM8V09l", + MinLength = 7, + })); + builder.Services.AddScoped(); if (!builder.Environment.IsDevelopment()) { @@ -154,12 +155,6 @@ private static void Main(string[] args) WebApplication app = builder.Build(); - app.Use((context, next) => - { - context.Request.Scheme = "https"; - return next(context); - }); - app.UseForwardedHeaders(); // Configure the HTTP request pipeline. @@ -180,14 +175,21 @@ private static void Main(string[] args) app.UseAuthentication(); app.UseAuthorization(); + app.UseMiddleware(); + + app.Use((context, next) => + { + context.Request.Scheme = "https"; + return next(context); + }); app.MapDefaultControllerRoute(); + app.MapRazorPages(); app.MapControllerRoute( name: "slug", pattern: "{*key}", defaults: new { controller = "Home", action = "Index" }); - app.MapRazorPages(); app.Run(); } diff --git a/EssentialCSharp.Web/Services/IReferralService.cs b/EssentialCSharp.Web/Services/IReferralService.cs new file mode 100644 index 00000000..f25219bd --- /dev/null +++ b/EssentialCSharp.Web/Services/IReferralService.cs @@ -0,0 +1,9 @@ +using System.Security.Claims; + +namespace EssentialCSharp.Web.Services; + +public interface IReferralService +{ + Task TrackReferralAsync(string referralId, ClaimsPrincipal? user); + Task GetReferralIdAsync(string userId); +} diff --git a/EssentialCSharp.Web/Services/ReferralService.cs b/EssentialCSharp.Web/Services/ReferralService.cs new file mode 100644 index 00000000..e0cb643f --- /dev/null +++ b/EssentialCSharp.Web/Services/ReferralService.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Sqids; + +namespace EssentialCSharp.Web.Services; + +public class ReferralService(EssentialCSharpWebContext dbContext, SqidsEncoder sqids, UserManager userManager) : IReferralService +{ + public async Task GetReferralIdAsync(string userId) + { + EssentialCSharpWebUser? user = await userManager.FindByIdAsync(userId); + if (user is null) + { + return null; + } + else + { + // Check if the user already has a referrer ID + if (!string.IsNullOrEmpty(user.ReferrerId)) + { + return user.ReferrerId; + } + else + { + Random random = new(); + string referrerId = sqids.Encode(random.Next()); + user.ReferrerId = referrerId; + + await userManager.AddClaimAsync(user, new Claim("ReferrerId", referrerId)); + await userManager.UpdateAsync(user); + return user.ReferrerId; + } + } + } + + /// + /// Track the referral in the database. + /// + /// The referrer ID to track. + /// True if the referral was successfully tracked, otherwise false. + public async Task TrackReferralAsync(string referralId, ClaimsPrincipal? user) + { + EssentialCSharpWebUser? claimsUser = user is null ? null : await userManager.GetUserAsync(user); + if (claimsUser is null) + { + return await TrackReferral(dbContext, referralId); + } + else + { + // If the user is the referrer, do not track the referral + if (claimsUser.ReferrerId == referralId) + { + return false; + } + else + { + return await TrackReferral(dbContext, referralId); + } + } + + static async Task TrackReferral(EssentialCSharpWebContext dbContext, string referralId) + { + EssentialCSharpWebUser? dbUser = await dbContext.Users.SingleOrDefaultAsync(u => u.ReferrerId == referralId); + if (dbUser is null) + { + return false; + } + else + { + dbUser.ReferralCount++; + await dbContext.SaveChangesAsync(); + return true; + } + } + } +} diff --git a/EssentialCSharp.Web/Services/ReferrerService.cs b/EssentialCSharp.Web/Services/ReferrerService.cs deleted file mode 100644 index 010d372d..00000000 --- a/EssentialCSharp.Web/Services/ReferrerService.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Sqids; - -namespace EssentialCSharp.Web.Services; - -public class ReferrerService(SqidsEncoder sqids) -{ - public string GenerateReferrerLink(string baseUrl, string userId) - { - string referrerId = sqids.Encode(1, 2, 3); - string referrerLink = $"{baseUrl}?referrerId={referrerId}"; - - // Store the referrerId and userId in the database for tracking - SaveReferrerIdToDatabase(userId, referrerId); - - return referrerLink; - } - - private void SaveReferrerIdToDatabase(string userId, string referrerId) - { - // Implement your database logic here - } - - /// - /// Track the referral in the database. - /// - /// The referrer ID to track. - /// True if the referral was successfully tracked, otherwise false. - public bool TrackReferral(string referrerId) - { - // Implement your logic to track the referral in the database - - if (sqids.Decode(referrerId) is [var decodedId] && - referrerId == sqids.Encode(decodedId)) - { - // `incomingId` decodes into a single number and is canonical, here you can safely proceed with the rest of the logic - } - else - { - // consider `incomingId` invalid — e.g. respond with 404 - } - IReadOnlyList numbers = sqids.Decode(referrerId); // [1, 2, 3] - } -} diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 88f94ce1..63894bce 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -271,8 +271,8 @@ } PREVIOUS_PAGE = @Json.Serialize(ViewBag.PreviousPage) - NEXT_PAGE = @Json.Serialize(ViewBag.NextPage) - TOC_DATA = @Json.Serialize(tocData) + NEXT_PAGE = @Json.Serialize(ViewBag.NextPage) + TOC_DATA = @Json.Serialize(tocData) @* Recursive vue component template for rendering the table of contents. *@ From 379f58bad186aea9124a0d0d91f95ebb18fb34e8 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 13 Feb 2025 15:17:02 -0800 Subject: [PATCH 03/10] Add ability to view referral count --- Directory.Packages.props | 9 ++--- .../Pages/Account/Manage/Index.cshtml | 2 +- .../Pages/Account/Manage/ManageNavPages.cs | 4 +++ .../Pages/Account/Manage/Referrals.cshtml | 19 +++++++++++ .../Pages/Account/Manage/Referrals.cshtml.cs | 34 +++++++++++++++++++ .../Pages/Account/Manage/_ManageNav.cshtml | 13 +++---- EssentialCSharp.Web/wwwroot/css/styles.css | 18 ++++++++-- 7 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml create mode 100644 EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ae9d0e58..6fac9b9b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,21 +16,22 @@ - - - - + + + + + diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml index f63c961f..0a330c17 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml @@ -32,7 +32,7 @@ ViewData["ActivePage"] = ManageNavPages.Index; - + diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs index a2952af2..e362bf1c 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs @@ -24,6 +24,8 @@ public static class ManageNavPages public static string TwoFactorAuthentication => "TwoFactorAuthentication"; + public static string Referrals => "Referrals"; + public static string? IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); public static string? EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email); @@ -40,6 +42,8 @@ public static class ManageNavPages public static string? TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication); + public static string? ReferralsNavClass(ViewContext viewContext) => PageNavClass(viewContext, Referrals); + public static string? PageNavClass(ViewContext viewContext, string page) { string? activePage = viewContext.ViewData["ActivePage"] as string diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml new file mode 100644 index 00000000..badd0ac0 --- /dev/null +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml @@ -0,0 +1,19 @@ +@page +@model ReferralsDataModel +@{ + ViewData["Title"] = "Referrals"; + ViewData["ActivePage"] = ManageNavPages.Referrals; +} + +

@ViewData["Title"]

+ +
+
+

+ You have @Model.ReferralCount referrals. +

+
+ + @section Scripts { + + } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs new file mode 100644 index 00000000..3c1d86dc --- /dev/null +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs @@ -0,0 +1,34 @@ +using EssentialCSharp.Web.Areas.Identity.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage; + +public class ReferralsDataModel : PageModel +{ + private readonly UserManager _UserManager; + private readonly ILogger _Logger; + + public int ReferralCount { get; set; } + + public ReferralsDataModel( + UserManager userManager, + ILogger logger) + { + _UserManager = userManager; + _Logger = logger; + } + + public async Task OnGetAsync() + { + EssentialCSharpWebUser? user = await _UserManager.GetUserAsync(User); + if (user is null) + { + return NotFound($"Unable to load user with ID '{_UserManager.GetUserId(User)}'."); + } + ReferralCount = user.ReferralCount; + + return Page(); + } +} diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml index d4e28666..15dfee37 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml @@ -3,13 +3,14 @@ var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); } diff --git a/EssentialCSharp.Web/wwwroot/css/styles.css b/EssentialCSharp.Web/wwwroot/css/styles.css index d39016ad..3a0ee133 100644 --- a/EssentialCSharp.Web/wwwroot/css/styles.css +++ b/EssentialCSharp.Web/wwwroot/css/styles.css @@ -305,6 +305,18 @@ a:hover { color: var(--primary-accent-color); } +.account-nav-link { + color: black; +} + +.account-nav-link:hover { + color: var(--primary-accent-color); +} + +.account-nav-item .active { + color: var(--primary-accent-color); +} + /* Page Navigation Arrow Buttons */ .turn-page { @@ -809,9 +821,9 @@ details > summary::-webkit-details-marker { .nav-pills .nav-link.active, .nav-pills .show > .nav-link { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; } button.accept-policy { From 3926fc7f7166b1a1246eb85b17885a2ecfd4dba6 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 13 Feb 2025 15:51:47 -0800 Subject: [PATCH 04/10] Apply suggestions from code review --- .../Middleware/ReferralTrackingMiddleware.cs | 8 ++++---- EssentialCSharp.Web/Program.cs | 1 + .../{ => Referrals}/IReferralService.cs | 4 ++-- .../{ => Referrals}/ReferralService.cs | 18 +++++++++--------- 4 files changed, 16 insertions(+), 15 deletions(-) rename EssentialCSharp.Web/Services/{ => Referrals}/IReferralService.cs (51%) rename EssentialCSharp.Web/Services/{ => Referrals}/ReferralService.cs (81%) diff --git a/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs index 2ede6c99..c8d024c1 100644 --- a/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs +++ b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs @@ -1,13 +1,13 @@ using System.Security.Claims; using System.Web; using EssentialCSharp.Web.Areas.Identity.Data; -using EssentialCSharp.Web.Services; +using EssentialCSharp.Web.Services.Referrals; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Identity; namespace EssentialCSharp.Web.Middleware; -public class ReferralMiddleware +public sealed class ReferralMiddleware { private readonly RequestDelegate _Next; @@ -23,7 +23,7 @@ public async Task InvokeAsync(HttpContext context, IReferralService referralServ string? referralId = query["rid"]; string? userReferralId; - if (context.User is { } claimsUser && claimsUser.Identity is not null && claimsUser.Identity.IsAuthenticated) + if (context.User is { Identity.IsAuthenticated: true } claimsUser) { if (!string.IsNullOrWhiteSpace(referralId)) { @@ -71,7 +71,7 @@ static async Task TrackReferralAsync(IReferralService referralService, string? r { if (!string.IsNullOrWhiteSpace(referralId)) { - _ = await referralService.TrackReferralAsync(referralId, claimsUser); + await referralService.TrackReferralAsync(referralId, claimsUser); } } } diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 8ced257d..8a56cbec 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -4,6 +4,7 @@ using EssentialCSharp.Web.Extensions; using EssentialCSharp.Web.Middleware; using EssentialCSharp.Web.Services; +using EssentialCSharp.Web.Services.Referrals; using Mailjet.Client; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; diff --git a/EssentialCSharp.Web/Services/IReferralService.cs b/EssentialCSharp.Web/Services/Referrals/IReferralService.cs similarity index 51% rename from EssentialCSharp.Web/Services/IReferralService.cs rename to EssentialCSharp.Web/Services/Referrals/IReferralService.cs index f25219bd..5d65cd5c 100644 --- a/EssentialCSharp.Web/Services/IReferralService.cs +++ b/EssentialCSharp.Web/Services/Referrals/IReferralService.cs @@ -1,9 +1,9 @@ using System.Security.Claims; -namespace EssentialCSharp.Web.Services; +namespace EssentialCSharp.Web.Services.Referrals; public interface IReferralService { - Task TrackReferralAsync(string referralId, ClaimsPrincipal? user); + Task TrackReferralAsync(string referralId, ClaimsPrincipal? user); Task GetReferralIdAsync(string userId); } diff --git a/EssentialCSharp.Web/Services/ReferralService.cs b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs similarity index 81% rename from EssentialCSharp.Web/Services/ReferralService.cs rename to EssentialCSharp.Web/Services/Referrals/ReferralService.cs index e0cb643f..4431f618 100644 --- a/EssentialCSharp.Web/Services/ReferralService.cs +++ b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; using Sqids; -namespace EssentialCSharp.Web.Services; +namespace EssentialCSharp.Web.Services.Referrals; public class ReferralService(EssentialCSharpWebContext dbContext, SqidsEncoder sqids, UserManager userManager) : IReferralService { @@ -25,7 +25,7 @@ public class ReferralService(EssentialCSharpWebContext dbContext, SqidsEncoder /// The referrer ID to track. /// True if the referral was successfully tracked, otherwise false. - public async Task TrackReferralAsync(string referralId, ClaimsPrincipal? user) + public async Task TrackReferralAsync(string referralId, ClaimsPrincipal? user) { EssentialCSharpWebUser? claimsUser = user is null ? null : await userManager.GetUserAsync(user); if (claimsUser is null) { - return await TrackReferral(dbContext, referralId); + await TrackReferral(dbContext, referralId); } else { // If the user is the referrer, do not track the referral if (claimsUser.ReferrerId == referralId) { - return false; + return; } else { - return await TrackReferral(dbContext, referralId); + await TrackReferral(dbContext, referralId); } } - static async Task TrackReferral(EssentialCSharpWebContext dbContext, string referralId) + static async Task TrackReferral(EssentialCSharpWebContext dbContext, string referralId) { EssentialCSharpWebUser? dbUser = await dbContext.Users.SingleOrDefaultAsync(u => u.ReferrerId == referralId); if (dbUser is null) { - return false; + return; } else { dbUser.ReferralCount++; await dbContext.SaveChangesAsync(); - return true; + return; } } } From ceae0fc5981b776cf1dc8970f50edb548767e035 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 14 Feb 2025 11:00:46 -0800 Subject: [PATCH 05/10] Concurrency --- .editorconfig | 4 +- EssentialCSharp.Web/Program.cs | 2 +- .../Services/Referrals/ReferralService.cs | 41 +++++++++++++++++-- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/.editorconfig b/.editorconfig index c73a38b3..8b6b66f0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -125,9 +125,9 @@ csharp_preserve_single_line_blocks = true # IntelliTect Conventions # ############################### # var preferences -csharp_style_var_for_built_in_types = false:warning +csharp_style_var_for_built_in_types = false:none csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = false:warning +csharp_style_var_elsewhere = false:none ## Naming # Style Definitions diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 8a56cbec..069688ce 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -25,7 +25,7 @@ private static void Main(string[] args) builder.Logging.AddConsole(); builder.Services.AddHealthChecks(); - builder.Services.AddDbContext(options => options.UseSqlServer(connectionString)); + builder.Services.AddDbContextFactory(options => options.UseSqlServer(connectionString)); builder.Services.AddDefaultIdentity(options => { // Password settings diff --git a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs index 4431f618..d1236d46 100644 --- a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs +++ b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs @@ -1,4 +1,4 @@ -using System.Security.Claims; +using System.Security.Claims; using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Data; using Microsoft.AspNetCore.Identity; @@ -70,9 +70,42 @@ static async Task TrackReferral(EssentialCSharpWebContext dbContext, string refe } else { - dbUser.ReferralCount++; - await dbContext.SaveChangesAsync(); - return; + bool saved = false; + while (!saved) + { + try + { + dbUser.ReferralCount++; + await dbContext.SaveChangesAsync(); + saved = true; + } + catch (DbUpdateConcurrencyException ex) + { + foreach (var entry in ex.Entries) + { + if (entry.Entity is EssentialCSharpWebUser) + { + var proposedValues = entry.CurrentValues; + var databaseValues = await entry.GetDatabaseValuesAsync(); + + if (databaseValues is not null) + { + var databaseReferralCount = (int?)databaseValues[nameof(EssentialCSharpWebUser.ReferralCount)]; + proposedValues[nameof(EssentialCSharpWebUser.ReferralCount)] = databaseReferralCount + 1; + + // Refresh original values to bypass next concurrency check + entry.OriginalValues.SetValues(databaseValues); + } + } + else + { + throw new NotSupportedException( + "Don't know how to handle concurrency conflicts for " + + entry.Metadata.Name); + } + } + } + } } } } From e3102299d754a8a8cd4464e4821441e238de16c0 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Fri, 14 Feb 2025 12:33:38 -0800 Subject: [PATCH 06/10] Add referral Id's into hyperlinks --- .../Data/EssentialCSharpWebContext.cs | 2 +- .../Identity/Pages/Account/Login.cshtml.cs | 5 ++- .../Pages/Account/Manage/Index.cshtml | 2 +- .../Controllers/HomeController.cs | 35 ++++++---------- .../Middleware/ReferralTrackingMiddleware.cs | 40 +------------------ EssentialCSharp.Web/Program.cs | 2 +- .../Services/Referrals/IReferralService.cs | 2 + .../Services/Referrals/ReferralService.cs | 5 +++ .../Views/Shared/_Layout.cshtml | 1 + EssentialCSharp.Web/wwwroot/css/styles.css | 6 +-- EssentialCSharp.Web/wwwroot/js/site.js | 13 +++++- 11 files changed, 44 insertions(+), 69 deletions(-) diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs index 55c91fe8..a13c4bfb 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs @@ -4,7 +4,7 @@ namespace EssentialCSharp.Web.Data; -public class EssentialCSharpWebContext(DbContextOptions options) : IdentityDbContext(options) +public class EssentialCSharpWebContext(DbContextOptions options) : IdentityDbContext(options) { protected override void OnModelCreating(ModelBuilder builder) { diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index b876456f..4a678662 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Services.Referrals; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -7,7 +8,7 @@ namespace EssentialCSharp.Web.Areas.Identity.Pages.Account; -public class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger) : PageModel +public class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService) : PageModel { private InputModel? _Input; [BindProperty] @@ -77,6 +78,8 @@ public async Task OnPostAsync(string? returnUrl = null) if (foundUser is not null) { result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true); + // Call the referral service to get the referral ID and set it onto the user claim + _ = await referralService.GetReferralIdAsync(foundUser); } else { diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml index 0a330c17..f63c961f 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml @@ -32,7 +32,7 @@ ViewData["ActivePage"] = ManageNavPages.Index; -
+ diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index 32237400..f2dfa090 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -7,25 +7,12 @@ namespace EssentialCSharp.Web.Controllers; -public class HomeController : Controller +public class HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IHttpContextAccessor httpContextAccessor) : Controller { - private readonly IConfiguration _Configuration; - private readonly IWebHostEnvironment _HostingEnvironment; - private readonly ISiteMappingService _SiteMappingService; - private readonly ILogger _Logger; - - public HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IConfiguration configuration) - { - _Logger = logger; - _HostingEnvironment = hostingEnvironment; - _SiteMappingService = siteMappingService; - _Configuration = configuration; - } - public IActionResult Index(string key) { // if no key (default case), then load up home page - SiteMapping? siteMapping = _SiteMappingService.SiteMappings.Find(key); + SiteMapping? siteMapping = siteMappingService.SiteMappings.Find(key); if (string.IsNullOrEmpty(key)) { @@ -33,7 +20,7 @@ public IActionResult Index(string key) } else if (siteMapping is not null) { - string filePath = Path.Combine(_HostingEnvironment.ContentRootPath, Path.Combine(siteMapping.PagePath)); + string filePath = Path.Combine(hostingEnvironment.ContentRootPath, Path.Combine(siteMapping.PagePath)); HtmlDocument doc = new(); doc.Load(filePath); string headHtml = doc.DocumentNode.Element("html").Element("head").InnerHtml; @@ -44,6 +31,8 @@ public IActionResult Index(string key) ViewBag.PreviousPage = FlipPage(siteMapping.ChapterNumber, siteMapping.PageNumber, false); ViewBag.HeadContents = headHtml; ViewBag.Contents = html; + // Set the referral Id for use in the front end if available + ViewBag.ReferralId = httpContextAccessor.HttpContext?.User?.Claims?.FirstOrDefault(f => f.Type == "ReferrerId")?.Value; return View(); } else @@ -84,19 +73,19 @@ public IActionResult Home() public IActionResult Guidelines() { ViewBag.PageTitle = "Coding Guidelines"; - FileInfo fileInfo = new(Path.Combine(_HostingEnvironment.ContentRootPath, "Guidelines", "guidelines.json")); + FileInfo fileInfo = new(Path.Combine(hostingEnvironment.ContentRootPath, "Guidelines", "guidelines.json")); if (!fileInfo.Exists) { return RedirectToAction(nameof(Error), new { errorMessage = "Guidelines could not be found", statusCode = 404 }); } - ViewBag.Guidelines = fileInfo.ReadGuidelineJsonFromInputDirectory(_Logger); + ViewBag.Guidelines = fileInfo.ReadGuidelineJsonFromInputDirectory(logger); ViewBag.GuidelinesUrl = Request.Path.Value; return View(); } private string FlipPage(int currentChapter, int currentPage, bool next) { - if (_SiteMappingService.SiteMappings.Count == 0) + if (siteMappingService.SiteMappings.Count == 0) { return ""; } @@ -107,18 +96,18 @@ private string FlipPage(int currentChapter, int currentPage, bool next) page = 1; } - SiteMapping? siteMap = _SiteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter && f.PageNumber == currentPage + page); + SiteMapping? siteMap = siteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter && f.PageNumber == currentPage + page); if (siteMap is null) { if (next) { - siteMap = _SiteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter + 1 && f.PageNumber == 1); + siteMap = siteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter + 1 && f.PageNumber == 1); } else { - int? previousPage = _SiteMappingService.SiteMappings.LastOrDefault(f => f.ChapterNumber == currentChapter - 1)?.PageNumber; - siteMap = _SiteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter - 1 && f.PageNumber == previousPage); + int? previousPage = siteMappingService.SiteMappings.LastOrDefault(f => f.ChapterNumber == currentChapter - 1)?.PageNumber; + siteMap = siteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter - 1 && f.PageNumber == previousPage); } if (siteMap is null) { diff --git a/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs index c8d024c1..a34ce923 100644 --- a/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs +++ b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs @@ -2,7 +2,6 @@ using System.Web; using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Services.Referrals; -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Identity; namespace EssentialCSharp.Web.Middleware; @@ -21,48 +20,13 @@ public async Task InvokeAsync(HttpContext context, IReferralService referralServ // Retrieve current referral Id for processing System.Collections.Specialized.NameValueCollection query = HttpUtility.ParseQueryString(context.Request.QueryString.Value!); string? referralId = query["rid"]; - string? userReferralId; - if (context.User is { Identity.IsAuthenticated: true } claimsUser) { - if (!string.IsNullOrWhiteSpace(referralId)) - { - await TrackReferralAsync(referralService, referralId, claimsUser); - } - - // Add the referralId to the request context if it exists on a user - EssentialCSharpWebUser? user = await userManager.GetUserAsync(claimsUser); - if (user is not null) - { - userReferralId = await referralService.GetReferralIdAsync(user.Id); - - if (!string.IsNullOrWhiteSpace(userReferralId) && (string.IsNullOrWhiteSpace(query["rid"]) || query["rid"] != userReferralId)) - { - query.Remove("rid"); - query.Add("rid", userReferralId); - var builder = new UriBuilder(context.Request.GetEncodedUrl()) - { - Query = query.ToString() - }; - context.Response.Redirect(builder.ToString()); - return; - } - } + await TrackReferralAsync(referralService, referralId, claimsUser); } else { - - if (!string.IsNullOrWhiteSpace(referralId)) - { - await TrackReferralAsync(referralService, referralId, null); - query.Remove("rid"); - var builder = new UriBuilder(context.Request.GetEncodedUrl()) - { - Query = query.ToString() - }; - context.Response.Redirect(builder.ToString()); - return; - } + await TrackReferralAsync(referralService, referralId, null); } await _Next(context); diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 069688ce..8a56cbec 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -25,7 +25,7 @@ private static void Main(string[] args) builder.Logging.AddConsole(); builder.Services.AddHealthChecks(); - builder.Services.AddDbContextFactory(options => options.UseSqlServer(connectionString)); + builder.Services.AddDbContext(options => options.UseSqlServer(connectionString)); builder.Services.AddDefaultIdentity(options => { // Password settings diff --git a/EssentialCSharp.Web/Services/Referrals/IReferralService.cs b/EssentialCSharp.Web/Services/Referrals/IReferralService.cs index 5d65cd5c..5fa4a436 100644 --- a/EssentialCSharp.Web/Services/Referrals/IReferralService.cs +++ b/EssentialCSharp.Web/Services/Referrals/IReferralService.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using EssentialCSharp.Web.Areas.Identity.Data; namespace EssentialCSharp.Web.Services.Referrals; @@ -6,4 +7,5 @@ public interface IReferralService { Task TrackReferralAsync(string referralId, ClaimsPrincipal? user); Task GetReferralIdAsync(string userId); + Task GetReferralIdAsync(EssentialCSharpWebUser? user); } diff --git a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs index d1236d46..82c3fee8 100644 --- a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs +++ b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs @@ -12,6 +12,11 @@ public class ReferralService(EssentialCSharpWebContext dbContext, SqidsEncoder GetReferralIdAsync(string userId) { EssentialCSharpWebUser? user = await userManager.FindByIdAsync(userId); + return await GetReferralIdAsync(user); + } + + public async Task GetReferralIdAsync(EssentialCSharpWebUser? user) + { if (user is null) { return null; diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 63894bce..c7268170 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -273,6 +273,7 @@ PREVIOUS_PAGE = @Json.Serialize(ViewBag.PreviousPage) NEXT_PAGE = @Json.Serialize(ViewBag.NextPage) TOC_DATA = @Json.Serialize(tocData) + REFERRAL_ID = @Json.Serialize(ViewBag.ReferralId) @* Recursive vue component template for rendering the table of contents. *@ diff --git a/EssentialCSharp.Web/wwwroot/css/styles.css b/EssentialCSharp.Web/wwwroot/css/styles.css index 3a0ee133..baf5d91a 100644 --- a/EssentialCSharp.Web/wwwroot/css/styles.css +++ b/EssentialCSharp.Web/wwwroot/css/styles.css @@ -821,9 +821,9 @@ details > summary::-webkit-details-marker { .nav-pills .nav-link.active, .nav-pills .show > .nav-link { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; } button.accept-policy { diff --git a/EssentialCSharp.Web/wwwroot/js/site.js b/EssentialCSharp.Web/wwwroot/js/site.js index a3e95e07..83636bb7 100644 --- a/EssentialCSharp.Web/wwwroot/js/site.js +++ b/EssentialCSharp.Web/wwwroot/js/site.js @@ -125,8 +125,13 @@ const app = createApp({ const snackbarColor = ref(); function copyToClipboard(copyText) { + let url = window.location.origin + "/" + copyText; + let referralId = REFERRAL_ID; + if (referralId && referralId.trim()) { + url = addQueryParam(url, 'rid', referralId); + } navigator.clipboard - .writeText(window.location.origin + "/" + copyText) + .writeText(url) .then( function () { /* Success */ @@ -151,6 +156,12 @@ const app = createApp({ ); } + function addQueryParam(url, key, value) { + let urlObj = new URL(url, window.location.origin); + urlObj.searchParams.set(key, value); + return urlObj.toString(); + } + function goToPrevious() { let previousPage = PREVIOUS_PAGE; if (previousPage !== null) { From b1cacc7693195f5733c0b25132869c4dce9459e6 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 17 Feb 2025 14:05:13 -0800 Subject: [PATCH 07/10] PR Feedback --- .../Identity/Data/EssentialCSharpWebUser.cs | 1 - .../Identity/Pages/Account/Login.cshtml.cs | 2 +- .../Controllers/HomeController.cs | 2 +- .../Extensions/ClaimsExtensions.cs | 18 +++ .../Middleware/ReferralTrackingMiddleware.cs | 8 +- .../Services/Referrals/IReferralService.cs | 4 +- .../Services/Referrals/ReferralService.cs | 106 ++++++------------ 7 files changed, 62 insertions(+), 79 deletions(-) create mode 100644 EssentialCSharp.Web/Extensions/ClaimsExtensions.cs diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs index d4119927..04e02555 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs @@ -9,7 +9,6 @@ public class EssentialCSharpWebUser : IdentityUser public virtual string? FirstName { get; set; } [ProtectedPersonalData] public virtual string? LastName { get; set; } - public string? ReferrerId { get; set; } public int ReferralCount { get; set; } } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index 4a678662..a3ea80a7 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -79,7 +79,7 @@ public async Task OnPostAsync(string? returnUrl = null) { result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true); // Call the referral service to get the referral ID and set it onto the user claim - _ = await referralService.GetReferralIdAsync(foundUser); + _ = await referralService.EnsureReferralIdAsync(foundUser); } else { diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index f2dfa090..2d36d2cb 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -32,7 +32,7 @@ public IActionResult Index(string key) ViewBag.HeadContents = headHtml; ViewBag.Contents = html; // Set the referral Id for use in the front end if available - ViewBag.ReferralId = httpContextAccessor.HttpContext?.User?.Claims?.FirstOrDefault(f => f.Type == "ReferrerId")?.Value; + ViewBag.ReferralId = httpContextAccessor.HttpContext?.User?.Claims?.FirstOrDefault(f => f.Type == ClaimsExtensions.ReferrerIdClaimType)?.Value; return View(); } else diff --git a/EssentialCSharp.Web/Extensions/ClaimsExtensions.cs b/EssentialCSharp.Web/Extensions/ClaimsExtensions.cs new file mode 100644 index 00000000..e469843e --- /dev/null +++ b/EssentialCSharp.Web/Extensions/ClaimsExtensions.cs @@ -0,0 +1,18 @@ +using System.Security.Claims; + +namespace EssentialCSharp.Web.Extensions +{ + public static class ClaimsExtensions + { + public static string? GetReferrerId(this ClaimsPrincipal claimsPrincipal) + { + return claimsPrincipal.FindFirstValue(ReferrerIdClaimType); + } + + public static string? GetReferrerId(this IList claims) + { + return claims.FirstOrDefault(claim => claim.Type == ReferrerIdClaimType)?.Value; + } + public const string ReferrerIdClaimType = "ReferrerId"; + } +} diff --git a/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs index a34ce923..c350fa46 100644 --- a/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs +++ b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs @@ -22,20 +22,20 @@ public async Task InvokeAsync(HttpContext context, IReferralService referralServ string? referralId = query["rid"]; if (context.User is { Identity.IsAuthenticated: true } claimsUser) { - await TrackReferralAsync(referralService, referralId, claimsUser); + TrackReferralAsync(referralService, referralId, claimsUser); } else { - await TrackReferralAsync(referralService, referralId, null); + TrackReferralAsync(referralService, referralId, null); } await _Next(context); - static async Task TrackReferralAsync(IReferralService referralService, string? referralId, ClaimsPrincipal? claimsUser) + static void TrackReferralAsync(IReferralService referralService, string? referralId, ClaimsPrincipal? claimsUser) { if (!string.IsNullOrWhiteSpace(referralId)) { - await referralService.TrackReferralAsync(referralId, claimsUser); + referralService.TrackReferralAsync(referralId, claimsUser); } } } diff --git a/EssentialCSharp.Web/Services/Referrals/IReferralService.cs b/EssentialCSharp.Web/Services/Referrals/IReferralService.cs index 5fa4a436..80bd8ccd 100644 --- a/EssentialCSharp.Web/Services/Referrals/IReferralService.cs +++ b/EssentialCSharp.Web/Services/Referrals/IReferralService.cs @@ -5,7 +5,7 @@ namespace EssentialCSharp.Web.Services.Referrals; public interface IReferralService { - Task TrackReferralAsync(string referralId, ClaimsPrincipal? user); + void TrackReferralAsync(string referralId, ClaimsPrincipal? user); Task GetReferralIdAsync(string userId); - Task GetReferralIdAsync(EssentialCSharpWebUser? user); + Task EnsureReferralIdAsync(EssentialCSharpWebUser? user); } diff --git a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs index 82c3fee8..db418f62 100644 --- a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs +++ b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Extensions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Sqids; @@ -12,10 +13,15 @@ public class ReferralService(EssentialCSharpWebContext dbContext, SqidsEncoder GetReferralIdAsync(string userId) { EssentialCSharpWebUser? user = await userManager.FindByIdAsync(userId); - return await GetReferralIdAsync(user); + return await EnsureReferralIdAsync(user); } - public async Task GetReferralIdAsync(EssentialCSharpWebUser? user) + /// + /// Ensure that the user has a referral ID. If the user does not have a referral ID, generate one and save it to the user. + /// + /// + /// + public async Task EnsureReferralIdAsync(EssentialCSharpWebUser? user) { if (user is null) { @@ -24,19 +30,23 @@ public class ReferralService(EssentialCSharpWebContext dbContext, SqidsEncoder claim.UserId == user.Id && claim.ClaimType == ClaimsExtensions.ReferrerIdClaimType)?.ClaimValue; + if (!string.IsNullOrEmpty(referrerId)) { - return user.ReferrerId; + // Add the referrer ID to the user's claims if it does not exist + if (!(await userManager.GetClaimsAsync(user)).Any(claim => claim.Type == ClaimsExtensions.ReferrerIdClaimType)) + { + await userManager.AddClaimAsync(user, new Claim(ClaimsExtensions.ReferrerIdClaimType, referrerId)); + } + return referrerId; } else { Random random = Random.Shared; - string referrerId = sqids.Encode(random.Next()); - user.ReferrerId = referrerId; + referrerId = sqids.Encode(random.Next()); - await userManager.AddClaimAsync(user, new Claim("ReferrerId", referrerId)); - await userManager.UpdateAsync(user); - return user.ReferrerId; + await userManager.AddClaimAsync(user, new Claim(ClaimsExtensions.ReferrerIdClaimType, referrerId)); + return referrerId; } } } @@ -46,72 +56,28 @@ public class ReferralService(EssentialCSharpWebContext dbContext, SqidsEncoder /// The referrer ID to track. /// True if the referral was successfully tracked, otherwise false. - public async Task TrackReferralAsync(string referralId, ClaimsPrincipal? user) + public void TrackReferralAsync(string referralId, ClaimsPrincipal? user) { - EssentialCSharpWebUser? claimsUser = user is null ? null : await userManager.GetUserAsync(user); - if (claimsUser is null) - { - await TrackReferral(dbContext, referralId); - } - else - { - // If the user is the referrer, do not track the referral - if (claimsUser.ReferrerId == referralId) - { - return; - } - else - { - await TrackReferral(dbContext, referralId); - } - } + // Check if the referrer ID exists in the claims principal + string? claimsReferrerId = user?.Claims.FirstOrDefault(c => c.Type == ClaimsExtensions.ReferrerIdClaimType)?.Value; - static async Task TrackReferral(EssentialCSharpWebContext dbContext, string referralId) + if (claimsReferrerId == referralId) { - EssentialCSharpWebUser? dbUser = await dbContext.Users.SingleOrDefaultAsync(u => u.ReferrerId == referralId); - if (dbUser is null) - { - return; - } - else - { - bool saved = false; - while (!saved) - { - try - { - dbUser.ReferralCount++; - await dbContext.SaveChangesAsync(); - saved = true; - } - catch (DbUpdateConcurrencyException ex) - { - foreach (var entry in ex.Entries) - { - if (entry.Entity is EssentialCSharpWebUser) - { - var proposedValues = entry.CurrentValues; - var databaseValues = await entry.GetDatabaseValuesAsync(); + // If the referrer ID in the claims principal matches the referral ID, do not track the referral + return; + } - if (databaseValues is not null) - { - var databaseReferralCount = (int?)databaseValues[nameof(EssentialCSharpWebUser.ReferralCount)]; - proposedValues[nameof(EssentialCSharpWebUser.ReferralCount)] = databaseReferralCount + 1; + TrackReferral(dbContext, referralId); + } - // Refresh original values to bypass next concurrency check - entry.OriginalValues.SetValues(databaseValues); - } - } - else - { - throw new NotSupportedException( - "Don't know how to handle concurrency conflicts for " - + entry.Metadata.Name); - } - } - } - } - } + static void TrackReferral(EssentialCSharpWebContext dbContext, string referralId) + { + var userClaim = dbContext.UserClaims.FirstOrDefault(claim => claim.ClaimType == ClaimsExtensions.ReferrerIdClaimType && claim.ClaimValue == referralId); + if (userClaim is null) + { + return; } + + dbContext.Users.Where(user => user.Id == userClaim.UserId).ExecuteUpdate(setters => setters.SetProperty(b => b.ReferralCount, b => b.ReferralCount + 1)); } } From 3cc0b4cb21301f794a6264be906efd4cce13c940 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 17 Feb 2025 14:05:20 -0800 Subject: [PATCH 08/10] Migration --- .../20250217215739_ReferralCount.Designer.cs | 292 ++++++++++++ .../20250217215739_ReferralCount.cs | 29 ++ .../EssentialCSharpWebContextModelSnapshot.cs | 414 +++++++++--------- 3 files changed, 530 insertions(+), 205 deletions(-) create mode 100644 EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.Designer.cs create mode 100644 EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.cs diff --git a/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.Designer.cs b/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.Designer.cs new file mode 100644 index 00000000..828dc5a8 --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.Designer.cs @@ -0,0 +1,292 @@ +// +using System; +using EssentialCSharp.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + [DbContext(typeof(EssentialCSharpWebContext))] + [Migration("20250217215739_ReferralCount")] + partial class ReferralCount + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + 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("ReferralCount") + .HasColumnType("int"); + + 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("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") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + 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") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + 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("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", 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("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.cs b/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.cs new file mode 100644 index 00000000..4b69bc67 --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + /// + public partial class ReferralCount : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ReferralCount", + table: "AspNetUsers", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ReferralCount", + table: "AspNetUsers"); + } + } +} diff --git a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs index ad3d0a1c..e220d69c 100644 --- a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs +++ b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs @@ -8,278 +8,282 @@ #nullable disable -namespace EssentialCSharp.Web.Migrations; - -[DbContext(typeof(EssentialCSharpWebContext))] -partial class EssentialCSharpWebContextModelSnapshot : ModelSnapshot +namespace EssentialCSharp.Web.Migrations { - protected override void BuildModel(ModelBuilder modelBuilder) + [DbContext(typeof(EssentialCSharpWebContext))] + partial class EssentialCSharpWebContextModelSnapshot : ModelSnapshot { + protected override void BuildModel(ModelBuilder modelBuilder) + { #pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "7.0.12") - .HasAnnotation("Relational:MaxIdentifierLength", 128); + modelBuilder + .HasAnnotation("ProductVersion", "8.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); - modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); + b.Property("AccessFailedCount") + .HasColumnType("int"); - b.Property("AccessFailedCount") - .HasColumnType("int"); + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("EmailConfirmed") + .HasColumnType("bit"); - b.Property("EmailConfirmed") - .HasColumnType("bit"); + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); - b.Property("FirstName") - .HasColumnType("nvarchar(max)"); + b.Property("LastName") + .HasColumnType("nvarchar(max)"); - b.Property("LastName") - .HasColumnType("nvarchar(max)"); + b.Property("LockoutEnabled") + .HasColumnType("bit"); - b.Property("LockoutEnabled") - .HasColumnType("bit"); + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); + b.Property("ReferralCount") + .HasColumnType("int"); - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); - b.ToTable("AspNetUsers", (string)null); - }); + b.ToTable("AspNetUsers", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); - b.ToTable("AspNetRoles", (string)null); - }); + b.ToTable("AspNetRoles", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("RoleId"); + b.HasIndex("RoleId"); - b.ToTable("AspNetRoleClaims", (string)null); - }); + b.ToTable("AspNetRoleClaims", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("UserId"); + b.HasIndex("UserId"); - b.ToTable("AspNetUserClaims", (string)null); - }); + b.ToTable("AspNetUserClaims", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("ProviderKey") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); - b.HasKey("LoginProvider", "ProviderKey"); + b.HasKey("LoginProvider", "ProviderKey"); - b.HasIndex("UserId"); + b.HasIndex("UserId"); - b.ToTable("AspNetUserLogins", (string)null); - }); + b.ToTable("AspNetUserLogins", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); - b.HasKey("UserId", "RoleId"); + b.HasKey("UserId", "RoleId"); - b.HasIndex("RoleId"); + b.HasIndex("RoleId"); - b.ToTable("AspNetUserRoles", (string)null); - }); + b.ToTable("AspNetUserRoles", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("Name") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("Value") - .HasColumnType("nvarchar(max)"); + b.Property("Value") + .HasColumnType("nvarchar(max)"); - b.HasKey("UserId", "LoginProvider", "Name"); + b.HasKey("UserId", "LoginProvider", "Name"); - b.ToTable("AspNetUserTokens", (string)null); - }); + b.ToTable("AspNetUserTokens", (string)null); + }); - 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("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", 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("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + 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("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", 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("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 + } } } From cb47d439ed601596c0946c23434721499f92f9e4 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Mon, 17 Feb 2025 14:50:28 -0800 Subject: [PATCH 09/10] Move from sqids --- Directory.Packages.props | 3 --- EssentialCSharp.Web/EssentialCSharp.Web.csproj | 3 --- EssentialCSharp.Web/Program.cs | 10 +--------- .../Services/Referrals/ReferralService.cs | 11 +++++++---- 4 files changed, 8 insertions(+), 19 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6fac9b9b..e9818788 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,12 +24,10 @@ - - @@ -40,7 +38,6 @@ -
diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index 1653a4a4..cb8f0cd1 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -22,11 +22,9 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive @@ -35,7 +33,6 @@ - diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 8a56cbec..310ed18f 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -9,8 +9,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; -using Microsoft.EntityFrameworkCore; -using Sqids; +using Microsoft.EntityFrameworkCore; namespace EssentialCSharp.Web; @@ -107,13 +106,6 @@ private static void Main(string[] args) builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); builder.Services.AddSingleton(); builder.Services.AddHostedService(); - builder.Services.AddSingleton(new SqidsEncoder(new() - { - // This is a shuffled version of the default alphabet so the id's are at least unique to this site. - // This being open source, it will be easy to decode the ids, but these id's are not meant to be secure. - Alphabet = "imx4z2Ys7GZLXDqT5IkUOEnyvwPKJtp13NWdeuH6rRhCcQogjM8V09l", - MinLength = 7, - })); builder.Services.AddScoped(); if (!builder.Environment.IsDevelopment()) diff --git a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs index db418f62..4c7889d4 100644 --- a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs +++ b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs @@ -4,11 +4,11 @@ using EssentialCSharp.Web.Extensions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Sqids; +using Microsoft.IdentityModel.Tokens; namespace EssentialCSharp.Web.Services.Referrals; -public class ReferralService(EssentialCSharpWebContext dbContext, SqidsEncoder sqids, UserManager userManager) : IReferralService +public class ReferralService(EssentialCSharpWebContext dbContext, UserManager userManager) : IReferralService { public async Task GetReferralIdAsync(string userId) { @@ -42,8 +42,11 @@ public class ReferralService(EssentialCSharpWebContext dbContext, SqidsEncoder claim.ClaimType == ClaimsExtensions.ReferrerIdClaimType && claim.ClaimValue == referrerId)); await userManager.AddClaimAsync(user, new Claim(ClaimsExtensions.ReferrerIdClaimType, referrerId)); return referrerId; From 2d7125e6c56ccdade8952768a53988567c5a0ab7 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 12 Mar 2025 16:15:17 -0700 Subject: [PATCH 10/10] Apply suggestions from code review --- .../Pages/Account/Manage/Referrals.cshtml | 17 +++++++-------- .../Pages/Account/Manage/Referrals.cshtml.cs | 2 +- .../Extensions/ClaimsExtensions.cs | 21 +++++++++---------- .../Middleware/ReferralTrackingMiddleware.cs | 19 +++++++---------- .../Services/Referrals/ReferralService.cs | 2 +- 5 files changed, 27 insertions(+), 34 deletions(-) diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml index badd0ac0..f228413a 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml @@ -7,13 +7,12 @@

@ViewData["Title"]

-
-
-

- You have @Model.ReferralCount referrals. -

-
+
+

+ You have @Model.ReferralCount referrals. +

+
- @section Scripts { - - } +@section Scripts { + +} diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs index 3c1d86dc..f6136cf5 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs @@ -10,7 +10,7 @@ public class ReferralsDataModel : PageModel private readonly UserManager _UserManager; private readonly ILogger _Logger; - public int ReferralCount { get; set; } + public int ReferralCount { get; private set; } public ReferralsDataModel( UserManager userManager, diff --git a/EssentialCSharp.Web/Extensions/ClaimsExtensions.cs b/EssentialCSharp.Web/Extensions/ClaimsExtensions.cs index e469843e..3705cb83 100644 --- a/EssentialCSharp.Web/Extensions/ClaimsExtensions.cs +++ b/EssentialCSharp.Web/Extensions/ClaimsExtensions.cs @@ -1,18 +1,17 @@ using System.Security.Claims; -namespace EssentialCSharp.Web.Extensions +namespace EssentialCSharp.Web.Extensions; + +public static class ClaimsExtensions { - public static class ClaimsExtensions + public static string? GetReferrerId(this ClaimsPrincipal claimsPrincipal) { - public static string? GetReferrerId(this ClaimsPrincipal claimsPrincipal) - { - return claimsPrincipal.FindFirstValue(ReferrerIdClaimType); - } + return claimsPrincipal.FindFirstValue(ReferrerIdClaimType); + } - public static string? GetReferrerId(this IList claims) - { - return claims.FirstOrDefault(claim => claim.Type == ReferrerIdClaimType)?.Value; - } - public const string ReferrerIdClaimType = "ReferrerId"; + public static string? GetReferrerId(this IList claims) + { + return claims.FirstOrDefault(claim => claim.Type == ReferrerIdClaimType)?.Value; } + public const string ReferrerIdClaimType = "ReferrerId"; } diff --git a/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs index c350fa46..27e72eef 100644 --- a/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs +++ b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs @@ -20,23 +20,18 @@ public async Task InvokeAsync(HttpContext context, IReferralService referralServ // Retrieve current referral Id for processing System.Collections.Specialized.NameValueCollection query = HttpUtility.ParseQueryString(context.Request.QueryString.Value!); string? referralId = query["rid"]; - if (context.User is { Identity.IsAuthenticated: true } claimsUser) + if (string.IsNullOrWhiteSpace(referralId)) { - TrackReferralAsync(referralService, referralId, claimsUser); + await _Next(context); + return; } - else + if (context.User is { Identity.IsAuthenticated: true } claimsUser) { - TrackReferralAsync(referralService, referralId, null); + referralService.TrackReferralAsync(referralId, claimsUser); } - - await _Next(context); - - static void TrackReferralAsync(IReferralService referralService, string? referralId, ClaimsPrincipal? claimsUser) + else { - if (!string.IsNullOrWhiteSpace(referralId)) - { - referralService.TrackReferralAsync(referralId, claimsUser); - } + referralService.TrackReferralAsync(referralId, null); } } } diff --git a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs index 4c7889d4..fea2cfb5 100644 --- a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs +++ b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs @@ -73,7 +73,7 @@ public void TrackReferralAsync(string referralId, ClaimsPrincipal? user) TrackReferral(dbContext, referralId); } - static void TrackReferral(EssentialCSharpWebContext dbContext, string referralId) + private static void TrackReferral(EssentialCSharpWebContext dbContext, string referralId) { var userClaim = dbContext.UserClaims.FirstOrDefault(claim => claim.ClaimType == ClaimsExtensions.ReferrerIdClaimType && claim.ClaimValue == referralId); if (userClaim is null)