using System.Collections.Generic; using System.IO; using System.Linq; using Dapper; using FastEndpoints; using GB5Shared.Connection; using GB5Shared.DTO.Framework.Enum; using GB5Shared.DTO.Framework.ResponseStandard; using GB5Shared.QueryExecutor; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using PartnerDAL.Query.PartnerBrand; namespace PartnerSL.EndPoints.PartnerLogin { // Public endpoint — no Login header required. // Two-hop resolution, both pre-auth, rooted at ConnectionName (the identifier // already passed into GetVersionUrlIdentifier/VersionCheck during app boot): // // Hop 1 (GB5System, via Gb5SystemConnectionString()) — routing only: // MSERVERCONFIG.CONNECTIONNAME -> CLIENTID -> MCLIENTDETAILS.PARTNERPRODUCTID // // Hop 2 (the tenant's OWN ProductionDB, via IQueryExecutor.QueryAsync // (string ConnectionName, ...) — same overload VersionDAL.VersionCheck // already uses pre-auth) — the actual brand data: // TPARTNERPRODUCT / TPARTNER / TPARTNERBRAND / TPARTNERPRODUCTBRAND / MFILE // // This matches where the data is actually deployed today (per-tenant, not // centralized) — see PartnerBrandQB.GET_TENANT_LOCAL_PUBLIC_BRAND for the // caveat on partners that span more than one client. public class GetPublicBrandByConnection : Endpoint> { private readonly IApplicationConnection _appConnection; private readonly IQueryExecutor _queryExecutor; private readonly ILogger _logger; public GetPublicBrandByConnection( IApplicationConnection appConnection, IQueryExecutor queryExecutor, ILogger logger) { _appConnection = appConnection; _queryExecutor = queryExecutor; _logger = logger; } public override void Configure() { Get("/Partner/GetPublicBrandByConnection"); AllowAnonymous(); } public class GetPublicBrandByConnectionRequest { [QueryParam] public string ConnectionName { get; set; } = ""; // Optional request-time override — preview a specific partner's brand // for this ConnectionName without touching MCLIENTDETAILS.PARTNERPRODUCTID. // Omit to get the normally-assigned partner (the Hop 1 routing lookup). [QueryParam] public int? PartnerId { get; set; } } public override async Task HandleAsync(GetPublicBrandByConnectionRequest req, CancellationToken ct) { try { await HandleCoreAsync(req, ct).ConfigureAwait(false); } catch (Exception ex) { // No step above this point had its own try/catch — a missing table (e.g. this // ConnectionName's tenant DB never had the Partner Platform schema applied), a // dropped connection, etc. would otherwise crash straight through to a raw, // empty-body 500. Log the real exception and return a diagnosable error instead. _logger.LogError(ex, "GetPublicBrandByConnection failed for ConnectionName='{ConnectionName}' PartnerId={PartnerId}.", req.ConnectionName, req.PartnerId); await Send.ResponseAsync(new ResponseStandardDTO { Body = "", ErrorBody = "Brand resolution failed — the connection's database may be missing the Partner Platform schema, or is unreachable. Check server logs for details.", Status = FrameworkEnumDTO.ResponseStatus.Failed }, 500, ct); } } private async Task HandleCoreAsync(GetPublicBrandByConnectionRequest req, CancellationToken ct) { if (string.IsNullOrWhiteSpace(req.ConnectionName)) { await Send.ResponseAsync(new ResponseStandardDTO { Body = "", ErrorBody = "ConnectionName is required.", Status = FrameworkEnumDTO.ResponseStatus.Failed }, 400, ct); return; } var connectionName = req.ConnectionName.Trim(); var baseUri = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; // ── Hop 1 — GB5System: resolve routing + PartnerProductId ────────── string systemConnStr = await _appConnection.Gb5SystemConnectionString().ConfigureAwait(false); using var systemConn = new SqlConnection(systemConnStr); var routing = (await systemConn.QueryAsync( PartnerBrandQB.GET_ROUTING_BY_CONNECTION_NAME, new { ConnectionName = connectionName }).ConfigureAwait(false)).AsList(); if (routing.Count == 0) { await Send.ResponseAsync(new ResponseStandardDTO { Body = "", ErrorBody = "No active server configuration found for the given connection name.", Status = FrameworkEnumDTO.ResponseStatus.Failed }, 404, ct); return; } if (routing.Count > 1) { await Send.ResponseAsync(new ResponseStandardDTO { Body = "", ErrorBody = "More than one active server configuration found for the given connection name. Contact administrator.", Status = FrameworkEnumDTO.ResponseStatus.Failed }, 409, ct); return; } int partnerProductId = (int)routing[0].PartnerProductId; // Request-time override: look up the PartnerProductId for the requested // PartnerId directly inside the tenant DB, bypassing the stored assignment. if (req.PartnerId.HasValue && req.PartnerId.Value > 0) { var overrideRows = await _queryExecutor.QueryAsync( connectionName, PartnerBrandQB.GET_PARTNERPRODUCTID_BY_PARTNER_ID, new { PartnerId = req.PartnerId.Value }).ConfigureAwait(false); var overrideRow = overrideRows.FirstOrDefault(); if (overrideRow == null) { await Send.ResponseAsync(new ResponseStandardDTO { Body = "", ErrorBody = $"No active PartnerProduct found for PartnerId {req.PartnerId.Value} in this connection's database.", Status = FrameworkEnumDTO.ResponseStatus.Failed }, 404, ct); return; } partnerProductId = (int)overrideRow.PARTNERPRODUCTID; } if (partnerProductId <= 0) { // Client not linked to a partner product yet — unbranded, platform defaults. await Send.ResponseAsync(new ResponseStandardDTO { Body = JsonConvert.SerializeObject(new { AppName = "GoodBooks", PrimaryColor = "#003399", SecondaryColor = "#0055CC", AccentColor = "#FF6600", FontFamily = "Inter" }), Status = FrameworkEnumDTO.ResponseStatus.Ok }, 200, ct); return; } // ── Hop 2 — the tenant's own ProductionDB, resolved by ConnectionName ── var brandRows = await _queryExecutor.QueryAsync( connectionName, PartnerBrandQB.GET_TENANT_LOCAL_PUBLIC_BRAND, new { PartnerProductId = partnerProductId, BaseUri = baseUri }).ConfigureAwait(false); var brand = brandRows.FirstOrDefault(); if (brand != null) { // LogoFileUrl/LogoDarkFileUrl/FaviconFileUrl are file paths, not HTTP // URLs, in this deployment. Two Base64 variants are provided per file: // *Base64 - full "data:image/png;base64,..." URI, for // *Base64Raw - payload only, no prefix, for tools/decoders that expect // just the Base64 string (the prefix's ':' and ';' characters // aren't valid Base64 and will make a plain decoder reject it) var brandDict = (IDictionary)brand; brandDict.TryGetValue("LogoFileUrl", out var logoUrl); brandDict.TryGetValue("LogoDarkFileUrl", out var logoDarkUrl); brandDict.TryGetValue("FaviconFileUrl", out var faviconUrl); var (logoUri, logoRaw) = TryReadFileAsBase64(logoUrl as string, "Logo"); var (logoDarkUri, logoDarkRaw) = TryReadFileAsBase64(logoDarkUrl as string, "LogoDark"); var (faviconUri, faviconRaw) = TryReadFileAsBase64(faviconUrl as string, "Favicon"); brandDict["LogoFileBase64"] = logoUri; brandDict["LogoFileBase64Raw"] = logoRaw; brandDict["LogoDarkFileBase64"] = logoDarkUri; brandDict["LogoDarkFileBase64Raw"] = logoDarkRaw; brandDict["FaviconFileBase64"] = faviconUri; brandDict["FaviconFileBase64Raw"] = faviconRaw; } await Send.ResponseAsync(new ResponseStandardDTO { Body = JsonConvert.SerializeObject(brand), Status = FrameworkEnumDTO.ResponseStatus.Ok }, 200, ct); } // Reads a logo/favicon file off disk and returns both the full data: URI and // the raw Base64 payload alone. Returns (null, null) — never throws — if the // path is empty, missing, or unreadable; a broken/missing logo shouldn't fail // the whole brand response. Every failure is logged with the reason (not found // vs. access denied vs. something else) so a null field is diagnosable from // server logs instead of being a silent mystery. private (string? dataUri, string? raw) TryReadFileAsBase64(string? path, string fieldName) { if (string.IsNullOrWhiteSpace(path)) { _logger.LogWarning("GetPublicBrandByConnection: {Field} has no path configured.", fieldName); return (null, null); } if (!File.Exists(path)) { _logger.LogWarning( "GetPublicBrandByConnection: {Field} file not found at '{Path}' — check the path exists " + "AND every parent directory is traversable by the app's service account (e.g. a file under " + "/root/... is unreachable by a non-root process even if the file itself is world-readable).", fieldName, path); return (null, null); } try { var bytes = File.ReadAllBytes(path); var raw = Convert.ToBase64String(bytes); var mime = Path.GetExtension(path).ToLowerInvariant() switch { ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".svg" => "image/svg+xml", ".webp" => "image/webp", ".ico" => "image/x-icon", _ => "application/octet-stream" }; return ($"data:{mime};base64,{raw}", raw); } catch (Exception ex) { _logger.LogWarning(ex, "GetPublicBrandByConnection: failed to read {Field} at '{Path}'.", fieldName, path); return (null, null); } } } }