From 4759fbbd69c427414fd3549dc4e6cac4a9e43310 Mon Sep 17 00:00:00 2001 From: Nick Date: Fri, 12 Sep 2025 22:01:09 +0100 Subject: [PATCH] Refactor buy link handling and add geo-resolution features - Refactored `BuyController` for improved click logging and centralized link management. - Updated `DiscoveryController` to enhance user request handling and added country context dependency. - Introduced `ClicksController` for managing click tracking and redirects. - Added `GeoResolutionMiddleware` to resolve user locations based on IP. - Created `BuyCatalog` for centralized management of buy links. - Introduced view models (`BuyLinksViewModel`, `DiscoveryPageViewModel`) for better data handling in views. - Added new files for geographical data handling (`GeoIpResult`, `HttpContextItemKeys`, `LinkChoice`, etc.). - Updated `_BuyBox.cshtml` to render buy options based on user location. - Modified `DataAccess` for saving and retrieving geographical data. --- .../{BuyController.cs => ClicksController.cs} | 125 ++-- .../Controllers/DiscoveryController.cs | 238 +++---- .../Middleware/GeoResolutionMiddleware.cs | 82 +++ CatherineLynwood/Models/BuyCatalog.cs | 270 ++++++++ CatherineLynwood/Models/BuyLinksViewModel.cs | 35 + .../Models/DIscoveryPageViewModel.cs | 16 + CatherineLynwood/Models/GeoIpResult.cs | 19 + .../Models/HttpContextItemKeys.cs | 13 + CatherineLynwood/Models/LinkChoice.cs | 13 + CatherineLynwood/Program.cs | 101 +-- CatherineLynwood/Services/CountryContext.cs | 20 + CatherineLynwood/Services/DataAccess.cs | 631 ++++++++++-------- CatherineLynwood/Services/GeoResolver.cs | 77 +++ CatherineLynwood/Services/ICountryContext.cs | 13 + CatherineLynwood/Services/IGeoResolver.cs | 11 + .../Views/Discovery/Index1.cshtml | 406 +---------- .../Views/Discovery/_BuyBox.cshtml | 165 +++++ CatherineLynwood/Views/Shared/_Layout.cshtml | 57 +- 18 files changed, 1369 insertions(+), 923 deletions(-) rename CatherineLynwood/Controllers/{BuyController.cs => ClicksController.cs} (78%) create mode 100644 CatherineLynwood/Middleware/GeoResolutionMiddleware.cs create mode 100644 CatherineLynwood/Models/BuyCatalog.cs create mode 100644 CatherineLynwood/Models/BuyLinksViewModel.cs create mode 100644 CatherineLynwood/Models/DIscoveryPageViewModel.cs create mode 100644 CatherineLynwood/Models/GeoIpResult.cs create mode 100644 CatherineLynwood/Models/HttpContextItemKeys.cs create mode 100644 CatherineLynwood/Models/LinkChoice.cs create mode 100644 CatherineLynwood/Services/CountryContext.cs create mode 100644 CatherineLynwood/Services/GeoResolver.cs create mode 100644 CatherineLynwood/Services/ICountryContext.cs create mode 100644 CatherineLynwood/Services/IGeoResolver.cs create mode 100644 CatherineLynwood/Views/Discovery/_BuyBox.cshtml diff --git a/CatherineLynwood/Controllers/BuyController.cs b/CatherineLynwood/Controllers/ClicksController.cs similarity index 78% rename from CatherineLynwood/Controllers/BuyController.cs rename to CatherineLynwood/Controllers/ClicksController.cs index 19749ed..55696fc 100644 --- a/CatherineLynwood/Controllers/BuyController.cs +++ b/CatherineLynwood/Controllers/ClicksController.cs @@ -3,15 +3,14 @@ using CatherineLynwood.Services; using Microsoft.AspNetCore.Mvc; -using System.Web; - namespace CatherineLynwood.Controllers { - [Route("go")] - public class BuyController : Controller + [Route("")] + public class ClicksController : Controller { private readonly DataAccess _dataAccess; - // Replace with DB/service + + // Central map of slugs -> destination URLs and metadata private static readonly Dictionary Links = new() { // --- Ingram direct (GB/US only) --- @@ -48,7 +47,7 @@ namespace CatherineLynwood.Controllers CountryGroup = "US" }, - // --- Amazon (all countries) --- + // --- Amazon (GB/US/CA/AU) --- ["amazon-hardback-gb"] = new BuyLink { Slug = "amazon-hardback-gb", @@ -188,7 +187,7 @@ namespace CatherineLynwood.Controllers ["apple-ebook-gb"] = new BuyLink { Slug = "apple-ebook-gb", - Url = "https://books.apple.com/gb/book/the-alpha-flame/id6747852729", + Url = "https://books.apple.com/gb/book/the-alpha-flame/id/6747852729", Retailer = "Apple Books", Format = "eBook", CountryGroup = "GB" @@ -196,7 +195,7 @@ namespace CatherineLynwood.Controllers ["apple-ebook-us"] = new BuyLink { Slug = "apple-ebook-us", - Url = "https://books.apple.com/us/book/the-alpha-flame/id6747852729", + Url = "https://books.apple.com/us/book/the-alpha-flame/id/6747852729", Retailer = "Apple Books", Format = "eBook", CountryGroup = "US" @@ -204,7 +203,7 @@ namespace CatherineLynwood.Controllers ["apple-ebook-ca"] = new BuyLink { Slug = "apple-ebook-ca", - Url = "https://books.apple.com/ca/book/the-alpha-flame/id6747852729", + Url = "https://books.apple.com/ca/book/the-alpha-flame/id/6747852729", Retailer = "Apple Books", Format = "eBook", CountryGroup = "CA" @@ -212,7 +211,7 @@ namespace CatherineLynwood.Controllers ["apple-ebook-au"] = new BuyLink { Slug = "apple-ebook-au", - Url = "https://books.apple.com/au/book/the-alpha-flame/id6747852729", + Url = "https://books.apple.com/au/book/the-alpha-flame/id/6747852729", Retailer = "Apple Books", Format = "eBook", CountryGroup = "AU" @@ -220,7 +219,7 @@ namespace CatherineLynwood.Controllers ["apple-ebook-ie"] = new BuyLink { Slug = "apple-ebook-ie", - Url = "https://books.apple.com/ie/book/the-alpha-flame/id6747852729", + Url = "https://books.apple.com/ie/book/the-alpha-flame/id/6747852729", Retailer = "Apple Books", Format = "eBook", CountryGroup = "IE" @@ -269,13 +268,15 @@ namespace CatherineLynwood.Controllers } }; - - public BuyController(DataAccess dataAccess) + public ClicksController(DataAccess dataAccess) { _dataAccess = dataAccess; } - [HttpGet("{slug}")] + // --------------------------------------------------------------------- + // GET /go/{slug} -> logs the click server-side, then redirects + // --------------------------------------------------------------------- + [HttpGet("go/{slug}")] public async Task Go(string slug) { if (!Links.TryGetValue(slug, out var link)) return NotFound(); @@ -285,6 +286,7 @@ namespace CatherineLynwood.Controllers var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); var qs = Request.QueryString.HasValue ? Request.QueryString.Value : string.Empty; + // Keep lightweight bot/protection checks if (HttpMethods.IsHead(Request.Method)) return Redirect(link.Url); if (IsLikelyBot(ua)) return Redirect(link.Url); @@ -292,25 +294,9 @@ namespace CatherineLynwood.Controllers if (!string.Equals(fetchMode, "navigate", StringComparison.OrdinalIgnoreCase)) return Redirect(link.Url); - if (!IsOurSiteReferer(referer, Request.Host.Host)) - return Redirect(link.Url); - - // Ensure we have a first-party session id - var sessionId = Request.Cookies["sid"]; - if (string.IsNullOrEmpty(sessionId)) - { - sessionId = Guid.NewGuid().ToString("N"); - Response.Cookies.Append("sid", sessionId, new CookieOptions - { - HttpOnly = true, - SameSite = SameSiteMode.Lax, - Secure = true, - MaxAge = TimeSpan.FromDays(365), - Path = "/" - }); - // carry on and log this click - } + EnsureSidCookie(out var sessionId); + // Persist the click _ = await _dataAccess.SaveBuyClick( dateTimeUtc: DateTime.UtcNow, slug: link.Slug, @@ -330,6 +316,61 @@ namespace CatherineLynwood.Controllers return Redirect(link.Url); } + // --------------------------------------------------------------------- + // POST /track/click?slug=amazon-paperback-gb&src=discovery + // Receives pings and records them (204 No Content). + // --------------------------------------------------------------------- + [HttpPost("track/click")] + public async Task TrackClick([FromQuery] string slug, [FromQuery] string src = "discovery") + { + if (string.IsNullOrWhiteSpace(slug)) return NoContent(); + + Links.TryGetValue(slug, out var link); + + var ua = Request.Headers["User-Agent"].ToString() ?? string.Empty; + var referer = Request.Headers["Referer"].ToString() ?? string.Empty; + var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); + var qs = Request.QueryString.HasValue ? Request.QueryString.Value : string.Empty; + + EnsureSidCookie(out var sessionId); + + _ = await _dataAccess.SaveBuyClick( + dateTimeUtc: DateTime.UtcNow, + slug: slug, + retailer: link?.Retailer ?? "", + format: link?.Format ?? "", + countryGroup: link?.CountryGroup ?? "", + ip: ip, + country: "", // optional for ping + userAgent: ua, + referer: referer, + page: src, + sessionId: sessionId, + queryString: qs, + destinationUrl: link?.Url ?? "" + ); + + return NoContent(); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + private void EnsureSidCookie(out string sessionId) + { + sessionId = Request.Cookies["sid"]!; + if (!string.IsNullOrEmpty(sessionId)) return; + + sessionId = Guid.NewGuid().ToString("N"); + Response.Cookies.Append("sid", sessionId, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Secure = true, + MaxAge = TimeSpan.FromDays(365), + Path = "/" + }); + } private static bool IsLikelyBot(string ua) { @@ -337,23 +378,13 @@ namespace CatherineLynwood.Controllers var needles = new[] { "bot","crawler","spider","preview","fetch","scan","analyzer", - "httpclient","python-requests","facebookexternalhit","Slackbot", - "WhatsApp","TelegramBot","Googlebot","AdsBot","Amazonbot", - "bingbot","DuckDuckBot","YandexBot","AhrefsBot","SemrushBot", - "Applebot","LinkedInBot","Discordbot","Embedly","Pinterestbot" + "httpclient","python-requests","facebookexternalhit","slackbot", + "whatsapp","telegrambot","googlebot","adsbot","amazonbot", + "bingbot","duckduckbot","yandexbot","ahrefsbot","semrushbot", + "applebot","linkedinbot","discordbot","embedly","pinterestbot" }; ua = ua.ToLowerInvariant(); - return needles.Any(n => ua.Contains(n.ToLowerInvariant())); + return needles.Any(n => ua.Contains(n)); } - - private static bool IsOurSiteReferer(string referer, string ourHost) - { - if (string.IsNullOrWhiteSpace(referer)) return false; - if (!Uri.TryCreate(referer, UriKind.Absolute, out var uri)) return false; - // handle www. and subdomains as you prefer - return uri.Host.EndsWith(ourHost, StringComparison.OrdinalIgnoreCase); - } - - } } diff --git a/CatherineLynwood/Controllers/DiscoveryController.cs b/CatherineLynwood/Controllers/DiscoveryController.cs index a5f2e33..0ef946d 100644 --- a/CatherineLynwood/Controllers/DiscoveryController.cs +++ b/CatherineLynwood/Controllers/DiscoveryController.cs @@ -1,95 +1,55 @@ -using CatherineLynwood.Models; +using System.Globalization; +using System.Text.RegularExpressions; + +using CatherineLynwood.Models; using CatherineLynwood.Services; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; -using System.Globalization; -using System.Text.Json; -using System.Text.RegularExpressions; - namespace CatherineLynwood.Controllers { [Route("the-alpha-flame/discovery")] public class DiscoveryController : Controller { - #region Private Fields + private readonly DataAccess _dataAccess; + private readonly ICountryContext _country; - private DataAccess _dataAccess; - - #endregion Private Fields - - #region Public Constructors - - public DiscoveryController(DataAccess dataAccess) + public DiscoveryController(DataAccess dataAccess, ICountryContext country) { _dataAccess = dataAccess; + _country = country; } - #endregion Public Constructors - - #region Public Methods - - [Route("chapters/chapter-1-beth")] - public IActionResult Chapter1() - { - return View(); - } - - [Route("chapters/chapter-13-susie")] - public IActionResult Chapter13() - { - return View(); - } - - [Route("chapters/chapter-2-maggie")] - public IActionResult Chapter2() - { - return View(); - } - - [BookAccess(1, 1)] - [Route("extras/epilogue")] - public IActionResult Epilogue() - { - return View(); - } - - [BookAccess(1, 1)] - [Route("extras")] - public IActionResult Extras() - { - return View(); - } - - [Route("how-to-buy")] - public IActionResult HowToBuy() - { - return View(); - } + // ------------------------- + // Pages + // ------------------------- [Route("")] public async Task Index() { + // 1) Resolve country ISO2 (already set by middleware) + var iso2 = (_country.Iso2 ?? "GB").ToUpperInvariant(); + if (iso2 == "UK") iso2 = "GB"; // normalise + + // 2) Build server-side link slugs for this user + var buyLinks = BuildBuyLinksFor(iso2); + + // 3) Load reviews (unchanged) Reviews reviews = await _dataAccess.GetReviewsAsync(); reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 3); - return View("Index1", reviews); - } + // 4) Compose page VM + var vm = new DiscoveryPageViewModel + { + Reviews = reviews, + UserIso2 = iso2, + Buy = buyLinks + }; - [BookAccess(1, 1)] - [Route("extras/listen")] - public IActionResult Listen() - { - return View(); - } - - [BookAccess(1, 1)] - [Route("extras/maggies-designs")] - public IActionResult MaggiesDesigns() - { - return View(); + // Use your existing view (rename if you prefer): Index1.cshtml + return View("Index1", vm); } [Route("reviews")] @@ -97,67 +57,114 @@ namespace CatherineLynwood.Controllers { Reviews reviews = await _dataAccess.GetReviewsAsync(); reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 100); - + // If you prefer a strongly-typed page VM here too, you can wrap, but returning Reviews keeps your existing view working return View(reviews); } + [Route("how-to-buy")] + public IActionResult HowToBuy() => View(); + + [Route("chapters/chapter-1-beth")] + public IActionResult Chapter1() => View(); + + [Route("chapters/chapter-2-maggie")] + public IActionResult Chapter2() => View(); + + [Route("chapters/chapter-13-susie")] + public IActionResult Chapter13() => View(); + + [BookAccess(1, 1)] + [Route("extras")] + public IActionResult Extras() => View(); + + [BookAccess(1, 1)] + [Route("extras/epilogue")] + public IActionResult Epilogue() => View(); + [BookAccess(1, 1)] [Route("extras/scrap-book")] - public IActionResult ScrapBook() - { - return View(); - } + public IActionResult ScrapBook() => View(); + + [BookAccess(1, 1)] + [Route("extras/listen")] + public IActionResult Listen() => View(); + + [BookAccess(1, 1)] + [Route("extras/maggies-designs")] + public IActionResult MaggiesDesigns() => View(); [BookAccess(1, 1)] [Route("extras/soundtrack")] public async Task Soundtrack() { List soundtrackTrackModels = await _dataAccess.GetSoundtrack(); - return View(soundtrackTrackModels); } - [Route("trailer")] - public async Task Trailer() + // ------------------------- + // Private helpers + // ------------------------- + + private static BuyLinksViewModel BuildBuyLinksFor(string iso2) { - var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); - var userAgent = HttpContext.Request.Headers["User-Agent"].ToString(); + var cc = iso2.ToUpperInvariant(); + if (cc == "UK") cc = "GB"; - //ip = "77.104.168.236"; + string? ingHbSlug = null, ingPbSlug = null; + if (cc == "GB") { ingHbSlug = "ingram-hardback-gb"; ingPbSlug = "ingram-paperback-gb"; } + else if (cc == "US") { ingHbSlug = "ingram-hardback-us"; ingPbSlug = "ingram-paperback-us"; } - string country = "Unknown"; - using (var client = new HttpClient()) + string amazonHbSlug = cc switch { - try - { - var response = await client.GetStringAsync($"http://ip-api.com/json/{ip}"); - var json = JsonDocument.Parse(response); - country = json.RootElement.GetProperty("countryCode").GetString(); - - if (country == "GB") - { - country = "UK"; - } - } - catch (Exception ex) - { - // Fail silently - } - } - - FlagSupportViewModel flagSupportViewModel = await _dataAccess.GetTrailerLikes(); - - foreach (var flag in flagSupportViewModel.FlagCounts) + "GB" => "amazon-hardback-gb", + "US" => "amazon-hardback-us", + "CA" => "amazon-hardback-ca", + "AU" => "amazon-hardback-au", + _ => "amazon-hardback-us" + }; + string amazonPbSlug = cc switch { - flag.Selected = flag.Key.ToLower() == country.ToLower(); - } + "GB" => "amazon-paperback-gb", + "US" => "amazon-paperback-us", + "CA" => "amazon-paperback-ca", + "AU" => "amazon-paperback-au", + _ => "amazon-paperback-us" + }; + string amazonKindleSlug = cc switch + { + "GB" => "amazon-kindle-gb", + "US" => "amazon-kindle-us", + "CA" => "amazon-kindle-ca", + "AU" => "amazon-kindle-au", + _ => "amazon-kindle-us" + }; - return View(flagSupportViewModel); + string? natHbSlug = null, natPbSlug = null, natLabel = null; + if (cc == "GB") { natHbSlug = "waterstones-hardback-gb"; natPbSlug = "waterstones-paperback-gb"; natLabel = "Waterstones"; } + else if (cc == "US") { natHbSlug = "bn-hardback-us"; natPbSlug = "bn-paperback-us"; natLabel = "B&N"; } + + var ccLower = cc.ToLowerInvariant(); + var appleSlug = ccLower is "gb" or "us" or "ca" or "au" or "ie" ? $"apple-ebook-{ccLower}" : "apple-ebook-gb"; + var koboSlug = ccLower is "gb" or "us" or "ca" or "au" or "ie" ? $"kobo-ebook-{ccLower}" : "kobo-ebook-gb"; + + LinkChoice? mk(string? slug) => string.IsNullOrEmpty(slug) ? null : new LinkChoice { Slug = slug, Url = BuyCatalog.Url(slug) }; + + return new BuyLinksViewModel + { + Country = cc, + IngramHardback = mk(ingHbSlug), + IngramPaperback = mk(ingPbSlug), + AmazonHardback = mk(amazonHbSlug)!, + AmazonPaperback = mk(amazonPbSlug)!, + AmazonKindle = mk(amazonKindleSlug)!, + NationalHardback = mk(natHbSlug), + NationalPaperback = mk(natPbSlug), + NationalLabel = natLabel, + Apple = mk(appleSlug)!, + Kobo = mk(koboSlug)! + }; } - #endregion Public Methods - - #region Private Methods private string GenerateBookSchemaJsonLd(Reviews reviews, int take) { @@ -189,16 +196,12 @@ namespace CatherineLynwood.Controllers ["url"] = baseUrl }; - // Add review section if there are reviews + // Reviews if (reviews?.Items?.Any() == true) { var reviewObjects = new List>(); double total = 0; - - foreach (var review in reviews.Items) - { - total += review.RatingValue; - } + foreach (var review in reviews.Items) total += review.RatingValue; foreach (var review in reviews.Items.Take(take)) { @@ -230,7 +233,7 @@ namespace CatherineLynwood.Controllers }; } - // Add work examples + // Work examples schema["workExample"] = new List> { new Dictionary @@ -302,8 +305,7 @@ namespace CatherineLynwood.Controllers return JsonConvert.SerializeObject(schema, Formatting.Indented); } - private string StripHtml(string input) => string.IsNullOrWhiteSpace(input) ? string.Empty : Regex.Replace(input, "<.*?>", string.Empty); - - #endregion Private Methods + private static string StripHtml(string input) => + string.IsNullOrWhiteSpace(input) ? string.Empty : Regex.Replace(input, "<.*?>", string.Empty); } -} \ No newline at end of file +} diff --git a/CatherineLynwood/Middleware/GeoResolutionMiddleware.cs b/CatherineLynwood/Middleware/GeoResolutionMiddleware.cs new file mode 100644 index 0000000..ccc34c2 --- /dev/null +++ b/CatherineLynwood/Middleware/GeoResolutionMiddleware.cs @@ -0,0 +1,82 @@ +using CatherineLynwood.Models; +using CatherineLynwood.Services; + +using System.Net; + +namespace CatherineLynwood.Middleware +{ + + public sealed class GeoResolutionMiddleware + { + private readonly RequestDelegate _next; + private readonly IGeoResolver _resolver; + private readonly ILogger _logger; + private readonly IHostEnvironment _env; + + public GeoResolutionMiddleware( + RequestDelegate next, + IGeoResolver resolver, + ILogger logger, + IHostEnvironment env) + { + _next = next; _resolver = resolver; _logger = logger; _env = env; + } + + public async Task Invoke(HttpContext context) + { + // Basic trace to confirm middleware is running + _logger.LogInformation("GeoMW: path={Path}", context.Request.Path); + + var ip = GetClientIp(context); + + // In Development, replace loopback with a known public IP so ResolveAsync definitely runs + if (ip is null || IPAddress.IsLoopback(ip)) + { + if (_env.IsDevelopment()) + { + ip = IPAddress.Parse("81.145.211.224"); // UK + //ip = IPAddress.Parse("66.249.10.10"); // US + //ip = IPAddress.Parse("24.48.0.1"); // Canada + //ip = IPAddress.Parse("1.1.1.1"); // Australia + _logger.LogInformation("GeoMW: dev override IP -> {IP}", ip); + } + else + { + _logger.LogInformation("GeoMW: loopback or null IP in non-dev; skipping"); + await _next(context); + return; + } + } + + _logger.LogInformation("GeoMW: calling resolver for {IP}", ip); + + var geo = await _resolver.ResolveAsync(ip); + + if (geo is not null) + { + context.Items[HttpContextItemKeys.CountryIso2] = geo.Iso2; + context.Items[HttpContextItemKeys.CountryName] = geo.CountryName ?? ""; + context.Response.Headers["X-Geo-Iso2"] = geo.Iso2; // debug aid + _logger.LogInformation("GeoMW: resolved {IP} -> {ISO2} ({Country})", ip, geo.Iso2, geo.CountryName); + } + else + { + context.Response.Headers["X-Geo-Iso2"] = "none"; // debug aid + _logger.LogWarning("GeoMW: resolver returned null for {IP}", ip); + } + + await _next(context); + } + + private static IPAddress? GetClientIp(HttpContext ctx) + { + // Prefer X-Forwarded-For when present + if (ctx.Request.Headers.TryGetValue("X-Forwarded-For", out var xff)) + { + var first = xff.ToString().Split(',').Select(s => s.Trim()).FirstOrDefault(); + if (IPAddress.TryParse(first, out var parsed)) return parsed; + } + return ctx.Connection.RemoteIpAddress; + } + } +} diff --git a/CatherineLynwood/Models/BuyCatalog.cs b/CatherineLynwood/Models/BuyCatalog.cs new file mode 100644 index 0000000..1920e42 --- /dev/null +++ b/CatherineLynwood/Models/BuyCatalog.cs @@ -0,0 +1,270 @@ +using System.Collections.ObjectModel; + +namespace CatherineLynwood.Models +{ + public static class BuyCatalog + { + private static readonly Dictionary Links = new() + { + // --- Ingram direct (GB/US only) --- + ["ingram-hardback-gb"] = new BuyLink + { + Slug = "ingram-hardback-gb", + Url = "https://shop.ingramspark.com/b/084?params=GC1p1c8b66Rhfoy6Tq97SJmmhdZSEYuxBcCY5zxNstO", + Retailer = "Ingram", + Format = "Hardback", + CountryGroup = "GB" + }, + ["ingram-paperback-gb"] = new BuyLink + { + Slug = "ingram-paperback-gb", + Url = "https://shop.ingramspark.com/b/084?params=6easpH54PaugzXFKdF4Tu4Izb0cvkMqbj3ZNlaYBKMJ", + Retailer = "Ingram", + Format = "Paperback", + CountryGroup = "GB" + }, + ["ingram-hardback-us"] = new BuyLink + { + Slug = "ingram-hardback-us", + Url = "https://shop.ingramspark.com/b/084?params=GC1p1c8b66Rhfoy6Tq97SJmmhdZSEYuxBcCY5zxNstO", + Retailer = "Ingram", + Format = "Hardback", + CountryGroup = "US" + }, + ["ingram-paperback-us"] = new BuyLink + { + Slug = "ingram-paperback-us", + Url = "https://shop.ingramspark.com/b/084?params=6easpH54PaugzXFKdF4Tu4Izb0cvkMqbj3ZNlaYBKMJ", + Retailer = "Ingram", + Format = "Paperback", + CountryGroup = "US" + }, + + // --- Amazon (GB/US/CA/AU) --- + ["amazon-hardback-gb"] = new BuyLink + { + Slug = "amazon-hardback-gb", + Url = "https://www.amazon.co.uk/dp/1068225807", + Retailer = "Amazon", + Format = "Hardback", + CountryGroup = "GB" + }, + ["amazon-paperback-gb"] = new BuyLink + { + Slug = "amazon-paperback-gb", + Url = "https://www.amazon.co.uk/dp/1068225815", + Retailer = "Amazon", + Format = "Paperback", + CountryGroup = "GB" + }, + ["amazon-kindle-gb"] = new BuyLink + { + Slug = "amazon-kindle-gb", + Url = "https://www.amazon.co.uk/dp/B0FBS427VD", + Retailer = "Amazon", + Format = "Kindle", + CountryGroup = "GB" + }, + + ["amazon-hardback-us"] = new BuyLink + { + Slug = "amazon-hardback-us", + Url = "https://www.amazon.com/dp/1068225807", + Retailer = "Amazon", + Format = "Hardback", + CountryGroup = "US" + }, + ["amazon-paperback-us"] = new BuyLink + { + Slug = "amazon-paperback-us", + Url = "https://www.amazon.com/dp/1068225815", + Retailer = "Amazon", + Format = "Paperback", + CountryGroup = "US" + }, + ["amazon-kindle-us"] = new BuyLink + { + Slug = "amazon-kindle-us", + Url = "https://www.amazon.com/dp/B0FBS427VD", + Retailer = "Amazon", + Format = "Kindle", + CountryGroup = "US" + }, + + ["amazon-hardback-ca"] = new BuyLink + { + Slug = "amazon-hardback-ca", + Url = "https://www.amazon.ca/dp/1068225807", + Retailer = "Amazon", + Format = "Hardback", + CountryGroup = "CA" + }, + ["amazon-paperback-ca"] = new BuyLink + { + Slug = "amazon-paperback-ca", + Url = "https://www.amazon.ca/dp/1068225815", + Retailer = "Amazon", + Format = "Paperback", + CountryGroup = "CA" + }, + ["amazon-kindle-ca"] = new BuyLink + { + Slug = "amazon-kindle-ca", + Url = "https://www.amazon.ca/dp/B0FBS427VD", + Retailer = "Amazon", + Format = "Kindle", + CountryGroup = "CA" + }, + + ["amazon-hardback-au"] = new BuyLink + { + Slug = "amazon-hardback-au", + Url = "https://www.amazon.com.au/dp/1068225807", + Retailer = "Amazon", + Format = "Hardback", + CountryGroup = "AU" + }, + ["amazon-paperback-au"] = new BuyLink + { + Slug = "amazon-paperback-au", + Url = "https://www.amazon.com.au/dp/1068225815", + Retailer = "Amazon", + Format = "Paperback", + CountryGroup = "AU" + }, + ["amazon-kindle-au"] = new BuyLink + { + Slug = "amazon-kindle-au", + Url = "https://www.amazon.com.au/dp/B0FBS427VD", + Retailer = "Amazon", + Format = "Kindle", + CountryGroup = "AU" + }, + + // --- National retailers --- + ["waterstones-hardback-gb"] = new BuyLink + { + Slug = "waterstones-hardback-gb", + Url = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225802", + Retailer = "Waterstones", + Format = "Hardback", + CountryGroup = "GB" + }, + ["waterstones-paperback-gb"] = new BuyLink + { + Slug = "waterstones-paperback-gb", + Url = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225819", + Retailer = "Waterstones", + Format = "Paperback", + CountryGroup = "GB" + }, + + ["bn-hardback-us"] = new BuyLink + { + Slug = "bn-hardback-us", + Url = "https://www.barnesandnoble.com/s/9781068225802", + Retailer = "Barnes & Noble", + Format = "Hardback", + CountryGroup = "US" + }, + ["bn-paperback-us"] = new BuyLink + { + Slug = "bn-paperback-us", + Url = "https://www.barnesandnoble.com/s/9781068225819", + Retailer = "Barnes & Noble", + Format = "Paperback", + CountryGroup = "US" + }, + + // --- Apple Books (GB/US/CA/AU/IE) --- + ["apple-ebook-gb"] = new BuyLink + { + Slug = "apple-ebook-gb", + Url = "https://books.apple.com/gb/book/the-alpha-flame/id/6747852729", + Retailer = "Apple Books", + Format = "eBook", + CountryGroup = "GB" + }, + ["apple-ebook-us"] = new BuyLink + { + Slug = "apple-ebook-us", + Url = "https://books.apple.com/us/book/the-alpha-flame/id/6747852729", + Retailer = "Apple Books", + Format = "eBook", + CountryGroup = "US" + }, + ["apple-ebook-ca"] = new BuyLink + { + Slug = "apple-ebook-ca", + Url = "https://books.apple.com/ca/book/the-alpha-flame/id/6747852729", + Retailer = "Apple Books", + Format = "eBook", + CountryGroup = "CA" + }, + ["apple-ebook-au"] = new BuyLink + { + Slug = "apple-ebook-au", + Url = "https://books.apple.com/au/book/the-alpha-flame/id/6747852729", + Retailer = "Apple Books", + Format = "eBook", + CountryGroup = "AU" + }, + ["apple-ebook-ie"] = new BuyLink + { + Slug = "apple-ebook-ie", + Url = "https://books.apple.com/ie/book/the-alpha-flame/id/6747852729", + Retailer = "Apple Books", + Format = "eBook", + CountryGroup = "IE" + }, + + // --- Kobo (GB/US/CA/AU/IE) --- + ["kobo-ebook-gb"] = new BuyLink + { + Slug = "kobo-ebook-gb", + Url = "https://www.kobo.com/gb/en/ebook/the-alpha-flame", + Retailer = "Kobo", + Format = "eBook", + CountryGroup = "GB" + }, + ["kobo-ebook-us"] = new BuyLink + { + Slug = "kobo-ebook-us", + Url = "https://www.kobo.com/us/en/ebook/the-alpha-flame", + Retailer = "Kobo", + Format = "eBook", + CountryGroup = "US" + }, + ["kobo-ebook-ca"] = new BuyLink + { + Slug = "kobo-ebook-ca", + Url = "https://www.kobo.com/ca/en/ebook/the-alpha-flame", + Retailer = "Kobo", + Format = "eBook", + CountryGroup = "CA" + }, + ["kobo-ebook-au"] = new BuyLink + { + Slug = "kobo-ebook-au", + Url = "https://www.kobo.com/au/en/ebook/the-alpha-flame", + Retailer = "Kobo", + Format = "eBook", + CountryGroup = "AU" + }, + ["kobo-ebook-ie"] = new BuyLink + { + Slug = "kobo-ebook-ie", + Url = "https://www.kobo.com/ie/en/ebook/the-alpha-flame", + Retailer = "Kobo", + Format = "eBook", + CountryGroup = "IE" + } + }; + + public static BuyLink? Find(string slug) => + Links.TryGetValue(slug, out var link) ? link : null; + + public static string Url(string slug) => + Links.TryGetValue(slug, out var link) ? link.Url : ""; + } +} diff --git a/CatherineLynwood/Models/BuyLinksViewModel.cs b/CatherineLynwood/Models/BuyLinksViewModel.cs new file mode 100644 index 0000000..fc11be9 --- /dev/null +++ b/CatherineLynwood/Models/BuyLinksViewModel.cs @@ -0,0 +1,35 @@ +namespace CatherineLynwood.Models +{ + public sealed class BuyLinksViewModel + { + #region Public Properties + + // Amazon (always) + public LinkChoice AmazonHardback { get; set; } = new(); + + public LinkChoice AmazonKindle { get; set; } = new(); + + public LinkChoice AmazonPaperback { get; set; } = new(); + + // eBooks (always) + public LinkChoice Apple { get; set; } = new(); + + public string Country { get; set; } = "GB"; + + // Direct (optional) + public LinkChoice? IngramHardback { get; set; } + + public LinkChoice? IngramPaperback { get; set; } + + public LinkChoice Kobo { get; set; } = new(); + + // National (optional) + public LinkChoice? NationalHardback { get; set; } + + public string? NationalLabel { get; set; } + + public LinkChoice? NationalPaperback { get; set; } + + #endregion Public Properties + } +} \ No newline at end of file diff --git a/CatherineLynwood/Models/DIscoveryPageViewModel.cs b/CatherineLynwood/Models/DIscoveryPageViewModel.cs new file mode 100644 index 0000000..c05259f --- /dev/null +++ b/CatherineLynwood/Models/DIscoveryPageViewModel.cs @@ -0,0 +1,16 @@ +namespace CatherineLynwood.Models +{ + public sealed class DiscoveryPageViewModel + { + #region Public Properties + + // All slugs are for /go/{slug} and /track/click?slug={slug} + public BuyLinksViewModel Buy { get; set; } = new BuyLinksViewModel(); + + public Reviews Reviews { get; set; } = new Reviews(); + + public string UserIso2 { get; set; } = "GB"; + + #endregion Public Properties + } +} \ No newline at end of file diff --git a/CatherineLynwood/Models/GeoIpResult.cs b/CatherineLynwood/Models/GeoIpResult.cs new file mode 100644 index 0000000..7e6e76d --- /dev/null +++ b/CatherineLynwood/Models/GeoIpResult.cs @@ -0,0 +1,19 @@ +namespace CatherineLynwood.Models +{ + public class GeoIpResult + { + #region Public Properties + + public string CountryName { get; set; } = ""; + + public string Ip { get; set; } = ""; + + public string Iso2 { get; set; } = ""; + + public DateTime LastSeenUtc { get; set; } + + public string Source { get; set; } + + #endregion Public Properties + } +} \ No newline at end of file diff --git a/CatherineLynwood/Models/HttpContextItemKeys.cs b/CatherineLynwood/Models/HttpContextItemKeys.cs new file mode 100644 index 0000000..50d88dd --- /dev/null +++ b/CatherineLynwood/Models/HttpContextItemKeys.cs @@ -0,0 +1,13 @@ +namespace CatherineLynwood.Models +{ + public static class HttpContextItemKeys + { + #region Public Fields + + public const string CountryIso2 = "CountryIso2"; + + public const string CountryName = "CountryName"; + + #endregion Public Fields + } +} \ No newline at end of file diff --git a/CatherineLynwood/Models/LinkChoice.cs b/CatherineLynwood/Models/LinkChoice.cs new file mode 100644 index 0000000..bcdb7e9 --- /dev/null +++ b/CatherineLynwood/Models/LinkChoice.cs @@ -0,0 +1,13 @@ +namespace CatherineLynwood.Models +{ + public sealed class LinkChoice + { + #region Public Properties + + public string Slug { get; set; } = ""; + + public string Url { get; set; } = ""; + + #endregion Public Properties + } +} \ No newline at end of file diff --git a/CatherineLynwood/Program.cs b/CatherineLynwood/Program.cs index f741afe..fe1e622 100644 --- a/CatherineLynwood/Program.cs +++ b/CatherineLynwood/Program.cs @@ -1,10 +1,11 @@ -using System.IO.Compression; - -using CatherineLynwood.Middleware; +using CatherineLynwood.Middleware; using CatherineLynwood.Services; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.ResponseCompression; +using System.IO.Compression; + using WebMarkupMin.AspNetCoreLatest; namespace CatherineLynwood @@ -15,21 +16,44 @@ namespace CatherineLynwood { var builder = WebApplication.CreateBuilder(args); - // Retrieve the connection string from appsettings.json var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); + // DAL builder.Services.AddSingleton(new DataAccess(connectionString)); + // MVC builder.Services.AddControllersWithViews(); - // Add IHttpContextAccessor for accessing HTTP context in tag helpers + // HttpContext accessor builder.Services.AddHttpContextAccessor(); - builder.Services.AddHostedService(); + // Memory cache for IP cache + builder.Services.AddMemoryCache(); + // HttpClient for general use builder.Services.AddHttpClient(); - // ✅ Add session services (in-memory only) + // Named HttpClient for the GeoResolver with short timeout + builder.Services.AddHttpClient(nameof(GeoResolver), c => c.Timeout = TimeSpan.FromSeconds(3)); + + // Geo services + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + + // Background and other services you already use + builder.Services.AddHostedService(); + builder.Services.AddSingleton(sp => + { + var logger = sp.GetRequiredService>(); + return new RedirectsStore("redirects.json", logger); + }); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + builder.Services.AddSingleton(); + + // Session builder.Services.AddSession(options => { options.IdleTimeout = TimeSpan.FromMinutes(30); @@ -37,7 +61,7 @@ namespace CatherineLynwood options.Cookie.IsEssential = true; }); - // ✅ Add authentication with cookie settings + // Auth builder.Services.AddAuthentication("MyCookieAuth") .AddCookie("MyCookieAuth", options => { @@ -48,79 +72,63 @@ namespace CatherineLynwood options.ExpireTimeSpan = TimeSpan.FromHours(12); options.SlidingExpiration = true; }); - builder.Services.AddAuthorization(); - // Add RedirectsStore as singleton - builder.Services.AddSingleton(sp => - { - var logger = sp.GetRequiredService>(); - return new RedirectsStore("redirects.json", logger); - }); - - // ✅ Register the book access code service - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - builder.Services.AddSingleton(); - - // Add response compression services + // Response compression builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.Providers.Add(); options.Providers.Add(); }); + builder.Services.Configure(o => o.Level = CompressionLevel.Fastest); + builder.Services.Configure(o => o.Level = CompressionLevel.Fastest); - builder.Services.Configure(options => + // HTML minification + builder.Services.AddWebMarkupMin(o => { - options.Level = CompressionLevel.Fastest; - }); - - builder.Services.Configure(options => - { - options.Level = CompressionLevel.Fastest; - }); - - // Add HTML minification - builder.Services.AddWebMarkupMin(options => - { - options.AllowMinificationInDevelopmentEnvironment = true; + o.AllowMinificationInDevelopmentEnvironment = true; }) .AddHtmlMinification() .AddHttpCompression() .AddXmlMinification() .AddXhtmlMinification(); - builder.WebHost.ConfigureKestrel(options => - { - options.Limits.MaxRequestBodySize = 40 * 1024 * 1024; // 40MB - }); + // Kestrel limits + builder.WebHost.ConfigureKestrel(o => { o.Limits.MaxRequestBodySize = 40 * 1024 * 1024; }); var app = builder.Build(); - // Configure the HTTP request pipeline. + // Errors, HSTS if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Home/Error"); - app.UseHsts(); // Adds the HSTS (HTTP Strict Transport Security) header + app.UseHsts(); } + // If behind a proxy or CDN, capture real client IPs + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, + ForwardLimit = 2 + }); + + // Your existing custom middleware app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); + // Resolve ISO2 once per request, stored in HttpContext.Items via GeoResolutionMiddleware + app.UseMiddleware(); + app.UseHttpsRedirection(); app.UseResponseCompression(); app.UseStaticFiles(); app.UseWebMarkupMin(); app.UseRouting(); - app.UseSession(); - // ✅ Authentication must come before Authorization + app.UseSession(); app.UseAuthentication(); app.UseAuthorization(); @@ -129,6 +137,7 @@ namespace CatherineLynwood pattern: "{controller=Home}/{action=Index}/{id?}"); app.Run(); + } } } diff --git a/CatherineLynwood/Services/CountryContext.cs b/CatherineLynwood/Services/CountryContext.cs new file mode 100644 index 0000000..c5ea5a1 --- /dev/null +++ b/CatherineLynwood/Services/CountryContext.cs @@ -0,0 +1,20 @@ +using CatherineLynwood.Models; + +namespace CatherineLynwood.Services +{ + public sealed class CountryContext : ICountryContext + { + private readonly IHttpContextAccessor _http; + + public CountryContext(IHttpContextAccessor http) + { + _http = http; + } + + public string? Iso2 => + _http.HttpContext?.Items[HttpContextItemKeys.CountryIso2] as string; + + public string? CountryName => + _http.HttpContext?.Items[HttpContextItemKeys.CountryName] as string; + } +} diff --git a/CatherineLynwood/Services/DataAccess.cs b/CatherineLynwood/Services/DataAccess.cs index ccc743e..030ef43 100644 --- a/CatherineLynwood/Services/DataAccess.cs +++ b/CatherineLynwood/Services/DataAccess.cs @@ -65,64 +65,6 @@ namespace CatherineLynwood.Services return visible; } - public async Task SaveBuyClick( - DateTime dateTimeUtc, - string slug, - string retailer, - string format, - string countryGroup, - string ip, - string country, - string userAgent, - string referer, - string page, // the page on your site where the click happened - string sessionId, - string queryString, // original request query (e.g., utms, country override) - string destinationUrl // the final retailer URL you redirect to - ) - { - bool success = true; - - using (SqlConnection conn = new SqlConnection(_connectionString)) - using (SqlCommand cmd = new SqlCommand()) - { - try - { - await conn.OpenAsync(); - cmd.Connection = conn; - cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "SaveBuyClick"; - - // Required - cmd.Parameters.AddWithValue("@DateTimeUtc", dateTimeUtc); - cmd.Parameters.AddWithValue("@Slug", (object?)slug ?? DBNull.Value); - cmd.Parameters.AddWithValue("@Retailer", (object?)retailer ?? DBNull.Value); - cmd.Parameters.AddWithValue("@Format", (object?)format ?? DBNull.Value); - cmd.Parameters.AddWithValue("@CountryGroup", (object?)countryGroup ?? DBNull.Value); - - // Signals / attribution - cmd.Parameters.AddWithValue("@IP", (object?)ip ?? DBNull.Value); - cmd.Parameters.AddWithValue("@Country", (object?)country ?? DBNull.Value); - cmd.Parameters.AddWithValue("@UserAgent", (object?)userAgent ?? DBNull.Value); - cmd.Parameters.AddWithValue("@Referer", (object?)referer ?? DBNull.Value); - cmd.Parameters.AddWithValue("@Page", (object?)page ?? DBNull.Value); - cmd.Parameters.AddWithValue("@SessionId", (object?)sessionId ?? DBNull.Value); - cmd.Parameters.AddWithValue("@QueryString", (object?)queryString ?? DBNull.Value); - cmd.Parameters.AddWithValue("@DestinationUrl", (object?)destinationUrl ?? DBNull.Value); - - await cmd.ExecuteNonQueryAsync(); - } - catch (Exception) - { - success = false; - // optional: log the exception somewhere central - } - } - - return success; - } - - public async Task Addhoneypot(DateTime dateTime, string ip, string country, string userAgent, string referer) { bool success = true; @@ -155,122 +97,6 @@ namespace CatherineLynwood.Services return success; } - public async Task SaveFlagClick(string country) - { - int likes = 0; - - using (SqlConnection conn = new SqlConnection(_connectionString)) - { - using (SqlCommand cmd = new SqlCommand()) - { - try - { - await conn.OpenAsync(); - cmd.Connection = conn; - cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "SaveTrailerLike"; - cmd.Parameters.AddWithValue("@Country", country); - - using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) - { - while (await rdr.ReadAsync()) - { - likes = GetDataInt(rdr, "Likes"); - } - } - } - catch (Exception ex) - { - - } - } - } - - return likes; - } - - public async Task> GetSoundtrack() - { - List soundtrackTrackModels = new List(); - - using (SqlConnection conn = new SqlConnection(_connectionString)) - { - using (SqlCommand cmd = new SqlCommand()) - { - try - { - await conn.OpenAsync(); - cmd.Connection = conn; - cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "GetSoundtrack"; - - using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) - { - while (await rdr.ReadAsync()) - { - soundtrackTrackModels.Add(new SoundtrackTrackModel - { - AudioUrl = GetDataString(rdr, "AudioUrl"), - Chapter = GetDataString(rdr, "Chapter"), - Description = GetDataString(rdr, "Description"), - ImageUrl = GetDataString(rdr, "ImageUrl"), - LyricsHtml = GetDataString(rdr, "LyricsHtml"), - Title = GetDataString(rdr, "Title") - }); - } - } - } - catch (Exception ex) - { - - } - } - } - - - return soundtrackTrackModels; - } - - public async Task GetTrailerLikes() - { - FlagSupportViewModel flagSupportViewModel = new FlagSupportViewModel(); - - using (SqlConnection conn = new SqlConnection(_connectionString)) - { - using (SqlCommand cmd = new SqlCommand()) - { - try - { - await conn.OpenAsync(); - cmd.Connection = conn; - cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "GetTrailerLikes"; - - using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) - { - while (await rdr.ReadAsync()) - { - string country = GetDataString(rdr, "Country"); - int likes = GetDataInt(rdr, "Likes"); - - flagSupportViewModel.FlagCounts.Add(new FlagCount - { - Key = country, - Value = likes - }); - } - } - } - catch (Exception ex) - { - - } - } - } - - return flagSupportViewModel; - } - public async Task AddMarketingAsync(Marketing marketing) { bool success = true; @@ -415,47 +241,6 @@ namespace CatherineLynwood.Services return accessCodes; } - public async Task> GetAllBlogsAsync() - { - List list = new List(); - - using (SqlConnection conn = new SqlConnection(_connectionString)) - { - using (SqlCommand cmd = new SqlCommand()) - { - try - { - await conn.OpenAsync(); - cmd.Connection = conn; - cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "GetAllBlogs"; - - using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) - { - while (await rdr.ReadAsync()) - { - list.Add(new BlogSummaryResponse - { - AiSummary = GetDataString(rdr, "AiSummary"), - BlogUrl = GetDataString(rdr, "BlogUrl"), - Draft = GetDataBool(rdr, "Draft"), - IndexText = GetDataString(rdr, "IndexText"), - PublishDate = GetDataDate(rdr, "PublishDate"), - SubTitle = GetDataString(rdr, "SubTitle"), - Title = GetDataString(rdr, "Title"), - }); - } - } - } - catch (Exception ex) - { - } - } - } - - return list; - } - public async Task GetAllARCReadersAsync() { ARCReaderList arcReaderList = new ARCReaderList(); @@ -502,6 +287,47 @@ namespace CatherineLynwood.Services return arcReaderList; } + public async Task> GetAllBlogsAsync() + { + List list = new List(); + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "GetAllBlogs"; + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + list.Add(new BlogSummaryResponse + { + AiSummary = GetDataString(rdr, "AiSummary"), + BlogUrl = GetDataString(rdr, "BlogUrl"), + Draft = GetDataBool(rdr, "Draft"), + IndexText = GetDataString(rdr, "IndexText"), + PublishDate = GetDataDate(rdr, "PublishDate"), + SubTitle = GetDataString(rdr, "SubTitle"), + Title = GetDataString(rdr, "Title"), + }); + } + } + } + catch (Exception ex) + { + } + } + } + + return list; + } + public async Task GetBlogAdminIndexAsync() { BlogAdminIndex blogAdminIndex = new BlogAdminIndex(); @@ -758,6 +584,47 @@ namespace CatherineLynwood.Services return blogPosts; } + public async Task GetGeoIpAsync(string ip) + { + GeoIpResult? result = null; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "GetGeoIp"; // your stored procedure name + cmd.Parameters.AddWithValue("@Ip", ip); + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + if (await rdr.ReadAsync()) + { + result = new GeoIpResult + { + Ip = GetDataString(rdr, "IP"), + Iso2 = GetDataString(rdr, "ISO2"), + CountryName = GetDataString(rdr, "CountryName"), + LastSeenUtc = GetDataDate(rdr, "LastSeenUtc"), + Source = GetDataString(rdr, "Source") + }; + } + } + } + catch (Exception ex) + { + // log if you want + } + } + } + + return result; + } + public async Task GetQuestionsAsync() { Questions questions = new Questions(); @@ -874,6 +741,85 @@ namespace CatherineLynwood.Services return reviews; } + public async Task> GetSoundtrack() + { + List soundtrackTrackModels = new List(); + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "GetSoundtrack"; + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + soundtrackTrackModels.Add(new SoundtrackTrackModel + { + AudioUrl = GetDataString(rdr, "AudioUrl"), + Chapter = GetDataString(rdr, "Chapter"), + Description = GetDataString(rdr, "Description"), + ImageUrl = GetDataString(rdr, "ImageUrl"), + LyricsHtml = GetDataString(rdr, "LyricsHtml"), + Title = GetDataString(rdr, "Title") + }); + } + } + } + catch (Exception ex) + { + } + } + } + + return soundtrackTrackModels; + } + + public async Task GetTrailerLikes() + { + FlagSupportViewModel flagSupportViewModel = new FlagSupportViewModel(); + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "GetTrailerLikes"; + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + string country = GetDataString(rdr, "Country"); + int likes = GetDataInt(rdr, "Likes"); + + flagSupportViewModel.FlagCounts.Add(new FlagCount + { + Key = country, + Value = likes + }); + } + } + } + catch (Exception ex) + { + } + } + } + + return flagSupportViewModel; + } + public async Task MarkAsNotifiedAsync(int blogID) { bool success = true; @@ -901,73 +847,6 @@ namespace CatherineLynwood.Services return success; } - public async Task SaveBlogToDatabase(BlogPostRequest blog) - { - bool success = true; - - using (SqlConnection conn = new SqlConnection(_connectionString)) - { - using (SqlCommand cmd = new SqlCommand()) - { - try - { - await conn.OpenAsync(); - cmd.Connection = conn; - cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "SaveBlog"; - cmd.Parameters.AddWithValue("@AiSummary", blog.AiSummary); - cmd.Parameters.AddWithValue("@BlogUrl", blog.BlogUrl); - cmd.Parameters.AddWithValue("@ContentBottom", blog.ContentBottom); - cmd.Parameters.AddWithValue("@ContentTop", blog.ContentTop); - cmd.Parameters.AddWithValue("@ImageAlt", blog.ImageAlt); - cmd.Parameters.AddWithValue("@ImageDescription", blog.ImageDescription); - cmd.Parameters.AddWithValue("@ImageFirst", blog.ImagePosition.ToLower() == "left"); - cmd.Parameters.AddWithValue("@ImagePrompt", blog.ImagePrompt); - cmd.Parameters.AddWithValue("@IndexText", blog.IndexText); - cmd.Parameters.AddWithValue("@PublishDate", blog.PublishDate); - cmd.Parameters.AddWithValue("@ResponderID", blog.ResponderID); - cmd.Parameters.AddWithValue("@SubTitle", blog.SubTitle); - cmd.Parameters.AddWithValue("@Title", blog.Title); - await cmd.ExecuteNonQueryAsync(); - } - catch (Exception ex) - { - } - } - } - - return success; - } - - public async Task SaveContact(Contact contact, bool subscribe = false) - { - bool success = true; - - using (SqlConnection conn = new SqlConnection(_connectionString)) - { - using (SqlCommand cmd = new SqlCommand()) - { - try - { - await conn.OpenAsync(); - cmd.Connection = conn; - cmd.CommandType = CommandType.StoredProcedure; - cmd.CommandText = "SaveContact"; - cmd.Parameters.AddWithValue("@Name", contact.Name); - cmd.Parameters.AddWithValue("@EmailAddress", contact.EmailAddress); - cmd.Parameters.AddWithValue("@Subscribe", subscribe); - await cmd.ExecuteNonQueryAsync(); - } - catch (Exception ex) - { - success = false; - } - } - } - - return success; - } - public async Task SaveARCReaderApplication(ArcReaderApplicationModel arcReaderApplication) { bool success = true; @@ -1009,7 +888,195 @@ namespace CatherineLynwood.Services return success; } + public async Task SaveBlogToDatabase(BlogPostRequest blog) + { + bool success = true; + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveBlog"; + cmd.Parameters.AddWithValue("@AiSummary", blog.AiSummary); + cmd.Parameters.AddWithValue("@BlogUrl", blog.BlogUrl); + cmd.Parameters.AddWithValue("@ContentBottom", blog.ContentBottom); + cmd.Parameters.AddWithValue("@ContentTop", blog.ContentTop); + cmd.Parameters.AddWithValue("@ImageAlt", blog.ImageAlt); + cmd.Parameters.AddWithValue("@ImageDescription", blog.ImageDescription); + cmd.Parameters.AddWithValue("@ImageFirst", blog.ImagePosition.ToLower() == "left"); + cmd.Parameters.AddWithValue("@ImagePrompt", blog.ImagePrompt); + cmd.Parameters.AddWithValue("@IndexText", blog.IndexText); + cmd.Parameters.AddWithValue("@PublishDate", blog.PublishDate); + cmd.Parameters.AddWithValue("@ResponderID", blog.ResponderID); + cmd.Parameters.AddWithValue("@SubTitle", blog.SubTitle); + cmd.Parameters.AddWithValue("@Title", blog.Title); + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + } + } + } + + return success; + } + + public async Task SaveBuyClick( + DateTime dateTimeUtc, + string slug, + string retailer, + string format, + string countryGroup, + string ip, + string country, + string userAgent, + string referer, + string page, // the page on your site where the click happened + string sessionId, + string queryString, // original request query (e.g., utms, country override) + string destinationUrl // the final retailer URL you redirect to + ) + { + bool success = true; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveBuyClick"; + + // Required + cmd.Parameters.AddWithValue("@DateTimeUtc", dateTimeUtc); + cmd.Parameters.AddWithValue("@Slug", (object?)slug ?? DBNull.Value); + cmd.Parameters.AddWithValue("@Retailer", (object?)retailer ?? DBNull.Value); + cmd.Parameters.AddWithValue("@Format", (object?)format ?? DBNull.Value); + cmd.Parameters.AddWithValue("@CountryGroup", (object?)countryGroup ?? DBNull.Value); + + // Signals / attribution + cmd.Parameters.AddWithValue("@IP", (object?)ip ?? DBNull.Value); + cmd.Parameters.AddWithValue("@Country", (object?)country ?? DBNull.Value); + cmd.Parameters.AddWithValue("@UserAgent", (object?)userAgent ?? DBNull.Value); + cmd.Parameters.AddWithValue("@Referer", (object?)referer ?? DBNull.Value); + cmd.Parameters.AddWithValue("@Page", (object?)page ?? DBNull.Value); + cmd.Parameters.AddWithValue("@SessionId", (object?)sessionId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@QueryString", (object?)queryString ?? DBNull.Value); + cmd.Parameters.AddWithValue("@DestinationUrl", (object?)destinationUrl ?? DBNull.Value); + + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception) + { + success = false; + // optional: log the exception somewhere central + } + } + + return success; + } + + public async Task SaveContact(Contact contact, bool subscribe = false) + { + bool success = true; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveContact"; + cmd.Parameters.AddWithValue("@Name", contact.Name); + cmd.Parameters.AddWithValue("@EmailAddress", contact.EmailAddress); + cmd.Parameters.AddWithValue("@Subscribe", subscribe); + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + success = false; + } + } + } + + return success; + } + + public async Task SaveFlagClick(string country) + { + int likes = 0; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveTrailerLike"; + cmd.Parameters.AddWithValue("@Country", country); + + using (SqlDataReader rdr = await cmd.ExecuteReaderAsync()) + { + while (await rdr.ReadAsync()) + { + likes = GetDataInt(rdr, "Likes"); + } + } + } + catch (Exception ex) + { + } + } + } + + return likes; + } + + public async Task SaveGeoIpAsync(GeoIpResult geo) + { + bool success = true; + + using (SqlConnection conn = new SqlConnection(_connectionString)) + { + using (SqlCommand cmd = new SqlCommand()) + { + try + { + await conn.OpenAsync(); + cmd.Connection = conn; + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "SaveGeoIp"; // your stored procedure name + + cmd.Parameters.AddWithValue("@Ip", geo.Ip); + cmd.Parameters.AddWithValue("@Iso2", geo.Iso2); + cmd.Parameters.AddWithValue("@CountryName", geo.CountryName); + cmd.Parameters.AddWithValue("@LastSeenUtc", geo.LastSeenUtc); + cmd.Parameters.AddWithValue("@Source", geo.Source ?? (object)DBNull.Value); + + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + success = false; + } + } + } + + return success; + } public async Task UpdateBlogAsync(Blog blog) { diff --git a/CatherineLynwood/Services/GeoResolver.cs b/CatherineLynwood/Services/GeoResolver.cs new file mode 100644 index 0000000..102baf9 --- /dev/null +++ b/CatherineLynwood/Services/GeoResolver.cs @@ -0,0 +1,77 @@ +using CatherineLynwood.Models; + +using Microsoft.Extensions.Caching.Memory; + +using System.Net; +using System.Text.Json; + +namespace CatherineLynwood.Services +{ + public sealed class GeoResolver : IGeoResolver + { + private readonly IMemoryCache _cache; + private readonly DataAccess _dataAccess; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(6); + + public GeoResolver(IMemoryCache cache, DataAccess dataAccess, IHttpClientFactory httpClientFactory, ILogger logger) + { + _cache = cache; + _dataAccess = dataAccess; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task ResolveAsync(IPAddress ip, CancellationToken ct = default) + { + var ipStr = ip.ToString(); + + if (_cache.TryGetValue(ipStr, out var cached)) + return cached; + + // 1) Database lookup + var db = await _dataAccess.GetGeoIpAsync(ipStr); + if (db is not null) + { + _cache.Set(ipStr, db, CacheTtl); + return db; + } + + // 2) External API fallback + try + { + var client = _httpClientFactory.CreateClient(nameof(GeoResolver)); + var json = await client.GetStringAsync($"http://ip-api.com/json/{ipStr}?fields=status,country,countryCode", ct); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.GetProperty("status").GetString() == "success") + { + var iso2 = root.GetProperty("countryCode").GetString() ?? "UN"; + var country = root.GetProperty("country").GetString(); + + var geo = new GeoIpResult + { + Ip = ipStr, + Iso2 = iso2, + CountryName = country ?? "", + LastSeenUtc = DateTime.UtcNow, + Source = "ip-api" + }; + + await _dataAccess.SaveGeoIpAsync(geo); + _cache.Set(ipStr, geo, CacheTtl); + + return geo; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Geo lookup failed for {IP}", ipStr); + } + + return null; + } + } + +} diff --git a/CatherineLynwood/Services/ICountryContext.cs b/CatherineLynwood/Services/ICountryContext.cs new file mode 100644 index 0000000..fc83d3a --- /dev/null +++ b/CatherineLynwood/Services/ICountryContext.cs @@ -0,0 +1,13 @@ +namespace CatherineLynwood.Services +{ + public interface ICountryContext + { + #region Public Properties + + string? CountryName { get; } + + string? Iso2 { get; } + + #endregion Public Properties + } +} \ No newline at end of file diff --git a/CatherineLynwood/Services/IGeoResolver.cs b/CatherineLynwood/Services/IGeoResolver.cs new file mode 100644 index 0000000..2f468ac --- /dev/null +++ b/CatherineLynwood/Services/IGeoResolver.cs @@ -0,0 +1,11 @@ +using CatherineLynwood.Models; + +using System.Net; + +namespace CatherineLynwood.Services +{ + public interface IGeoResolver + { + Task ResolveAsync(IPAddress ip, CancellationToken ct = default); + } +} diff --git a/CatherineLynwood/Views/Discovery/Index1.cshtml b/CatherineLynwood/Views/Discovery/Index1.cshtml index 40ccc49..c3f81d9 100644 --- a/CatherineLynwood/Views/Discovery/Index1.cshtml +++ b/CatherineLynwood/Views/Discovery/Index1.cshtml @@ -1,8 +1,8 @@ -@model CatherineLynwood.Models.Reviews +@model CatherineLynwood.Models.DiscoveryPageViewModel @{ ViewData["Title"] = "The Alpha Flame: A Gritty 1980s Birmingham Crime Novel about Twin Sisters"; - bool showReviews = Model.Items.Any(); + bool showReviews = Model.Reviews.Items.Any(); } @@ -236,7 +105,7 @@ @if (showReviews) { - var top = Model.Items.Where(x => x.RatingValue == 5).OrderByDescending(y => y.DatePublished).First(); + var top = Model.Reviews.Items.Where(x => x.RatingValue == 5).OrderByDescending(y => y.DatePublished).First(); var fullStars = (int)Math.Floor(top.RatingValue); var hasHalfStar = top.RatingValue - fullStars >= 0.5; var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0); @@ -278,7 +147,7 @@ , @reviewDate - @if (Model.Items.Count > 1) + @if (Model.Reviews.Items.Count > 1) {
Read more reviews @@ -473,251 +342,6 @@ }); - - - - - - - - - - } @section Meta { @@ -737,6 +361,6 @@ twitter-creator-handle="@@CathLynwood" /> } diff --git a/CatherineLynwood/Views/Discovery/_BuyBox.cshtml b/CatherineLynwood/Views/Discovery/_BuyBox.cshtml new file mode 100644 index 0000000..0fc2c98 --- /dev/null +++ b/CatherineLynwood/Views/Discovery/_BuyBox.cshtml @@ -0,0 +1,165 @@ +@model CatherineLynwood.Models.DiscoveryPageViewModel +@{ + var L = Model?.Buy ?? new CatherineLynwood.Models.BuyLinksViewModel(); + var iso2 = (Model?.UserIso2 ?? "GB").ToUpperInvariant(); + if (iso2 == "UK") { iso2 = "GB"; } + var pingBase = "/track/click"; + var flagSvg = $"/images/flags/{iso2}.svg"; + var flagPng = $"/images/flags/{iso2}.png"; +} + +
+ +
+

Buy the Book

+ + + Best options for @iso2 + +
+ + @* --------------------------- + Row 1: Direct via printers (GB/US only) + --------------------------- *@ + @if (L.IngramHardback != null || L.IngramPaperback != null) + { +
+
+ Best price direct from our printers +
+
+ @if (L.IngramHardback != null) + { + + } + @if (L.IngramPaperback != null) + { + + } +
+
+ } + +
+ From other retailers +
+ + @* --------------------------- + Row 2: Amazon (always present) + --------------------------- *@ + + + @* --------------------------- + Row 3: National retailer (conditional) + --------------------------- *@ + @if (L.NationalHardback != null || L.NationalPaperback != null) + { +
+
+ @if (L.NationalHardback != null) + { + + } + @if (L.NationalPaperback != null) + { + + } +
+
+ } + + @* --------------------------- + Row 4: eBook stores (Apple, Kobo, Kindle) + --------------------------- *@ +
+
+ Choose your preferred e-book store +
+ +
+ + +
diff --git a/CatherineLynwood/Views/Shared/_Layout.cshtml b/CatherineLynwood/Views/Shared/_Layout.cshtml index 37fb698..967ddf2 100644 --- a/CatherineLynwood/Views/Shared/_Layout.cshtml +++ b/CatherineLynwood/Views/Shared/_Layout.cshtml @@ -248,45 +248,6 @@ *@ @RenderSection("Scripts", required: false) - - - + + +