using System.Security.Cryptography; using System.Text; using System.Text.Json; using GB5Shared.DTO.DrillDown; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.QueryExecutor; using Microsoft.Extensions.Configuration; using GB5Shared.Query.DrillDown; namespace GB5Shared.Export.DrillDown { // Resolves MDRILLDOWN/MDRILLDOWNDETAIL targets into real, navigable URLs for HTML/PDF export — // the same information reportviewer.component.ts already gets (unused, until now) via // Framework.ReportMenu.Get's DrillDownArray, but resolved server-side here since a static // export has no Angular app/DataPassingService to call at click time. // // The URL must decrypt correctly via gbmain.component.ts's decryptUsingAES256 — that function // (and CryptoJS.AES.encrypt(str, ) in general) does NOT use the key's raw // bytes directly. Passing a plain string key makes CryptoJS treat it as an OpenSSL-style // passphrase: a random 8-byte salt is generated, "Salted__" + salt is prepended to the // ciphertext, and the real AES key+IV are derived from (passphrase, salt) via EVP_BytesToKey // (iterated MD5) — verified byte-for-byte against this project's actual crypto-js dependency // before writing this class (encrypt in Node with crypto-js, decrypt independently in Python // with a hand-rolled EVP_BytesToKey, confirm the plaintext round-trips). Getting this wrong // fails silently — the FE decrypts garbage instead of throwing. public class DrillDownLinkResolver { private readonly IQueryExecutor _queryExecutor; private readonly string _cryptoKey; private readonly string? _frontendBaseUrl; public DrillDownLinkResolver(IQueryExecutor queryExecutor, IConfiguration configuration) { _queryExecutor = queryExecutor; // Must match gbconfig.json's serverDetails.gbmodulecryptokey EXACTLY — the two are // separate, duplicated config values (frontend vs. backend config systems), not shared // by any mechanism. If someone rotates one without the other, drill-down links silently // stop decrypting on the FE side; there is no automated check tying them together. _cryptoKey = configuration["DrillDown:FrontendCryptoKey"] ?? string.Empty; _frontendBaseUrl = configuration["DrillDown:FrontendBaseUrl"]; } // One call per export request (not per row) — cache the result across the rows being // rendered. Role-filtered by the exporting user's own RoleId, same as the FE's live path. public async Task> GetTargetsForMenuAsync( int menuId, LoginDTO loginDTO, CancellationToken ct) { // Note: MenuIds in this codebase are conventionally NEGATIVE (e.g. -1399999930) — do // not gate on sign, only on "unset" (0, matching int's default when nothing is bound). if (menuId == 0 || string.IsNullOrEmpty(_cryptoKey) || string.IsNullOrEmpty(_frontendBaseUrl)) return new List(); var results = await _queryExecutor.QueryAsync( loginDTO, DrillDownExportQB.GET_DRILLDOWN_TARGETS_FOR_MENU, new { MenuId = menuId, RoleId = loginDTO.RoleId }, cancellationToken: ct); return results.ToList(); } // Builds one {Href, ToMenuDisplayName} entry per target resolvable against this row — // i.e., whose SourceField is actually present in the row's data, regardless of whether // that field is a displayed column (it usually isn't — see the Phase 7 plan notes). // // `row` here is BuildChunkHtmlAsync's per-title Dictionary (keyed by display header, e.g. // "Voucher Number"), NOT the raw API record — a hidden field like "VoucherId" was never // copied into it (only reportViewFields' visible/pdfColumnHeaders entries are). The // original, unfiltered API row is preserved alongside it as row["__rawRecord"] // (Dictionary, set in BuildChunkHtmlAsync's row-building loop) — that's // what SourceField must be looked up against, matching how the Grid path already resolves // it directly off the raw row JSON (see gbslickgrid.component.ts's getResolvableDrillDownTargets). // `sourceCriteria` — the CURRENT report's own applied filter criteria (ReportCallingDTO. // CriteriaDTO.SectionCriteriaList), carried forward so a link clicked from a static export // doesn't land on the target with no context. There is no live app session at click time // (unlike the Grid path, which pulls this from DataPassingService.reportCriteria() — a // signal populated only within a running browser tab), so it has to travel IN the URL // itself. See reportviewer.component.ts's drilldownsettings() for the FE-side merge this // feeds — extended (SourceCriteria field) rather than duplicated, so both the Grid's live // navigation and this static-export path share one merge implementation. public List> ResolveLinksForRow( Dictionary row, List targets, List? sourceCriteria) { var links = new List>(); if (targets.Count == 0) return links; var sourceRow = row.TryGetValue("__rawRecord", out var raw) && raw is Dictionary rawRecord ? rawRecord : null; if (sourceRow == null) return links; foreach (var target in targets) { if (target.SourceField == null || !sourceRow.TryGetValue(target.SourceField, out var selectedIdValue)) continue; if (selectedIdValue == null) continue; var href = BuildDrillDownUrl(target.ToModuleId, target.ToMenuId, selectedIdValue, target.SourceField, target.AttributeId, sourceCriteria); links.Add(new Dictionary { ["Href"] = href, ["ToMenuDisplayName"] = target.ToMenuDisplayName ?? "View" }); } return links; } private string BuildDrillDownUrl( int moduleId, int menuId, object selectedId, string sourceField, int attributeId, List? sourceCriteria) { string encModuleId = EncryptUsingAES256(moduleId.ToString()); string encMenuId = EncryptUsingAES256(menuId.ToString()); string encSelectedId = EncryptUsingAES256(selectedId.ToString() ?? ""); // Flattened, minimal shape — only the fields drilldownsettings()'s merge loop actually // reads (CriteriaAttributeId/FieldName/FieldValue/OperationType/JoinType), not the full // AttributesCriteriaDTO (which also carries UI-only fields like IsHeader/TableAlias that // have no meaning once decoded on a cold-loaded target). var attributesCriteria = sourceCriteria?.FirstOrDefault()?.AttributesCriteriaList ?.Select(a => new { a.CriteriaAttributeId, a.FieldName, a.FieldValue, OperationType = (int)a.OperationType, JoinType = (int)a.JoinType, }) .ToList(); var drillCtx = new { SourceField = sourceField, AttributeId = attributeId, SourceCriteria = attributesCriteria, }; string encDrillCtx = EncryptUsingAES256(JsonSerializer.Serialize(drillCtx)); return $"{_frontendBaseUrl!.TrimEnd('/')}/main?moduleid={encModuleId}&menuid={encMenuId}" + $"&selectedid={encSelectedId}&drillctx={encDrillCtx}"; } // Mirrors gbmain.component.ts's encryptUsingAES256 exactly: OpenSSL-format // "Salted__" + 8-byte-salt + AES-256-ECB/PKCS7 ciphertext, base64, then made URL-safe. public string EncryptUsingAES256(string plaintext) { byte[] salt = RandomNumberGenerator.GetBytes(8); var (key, _) = EvpBytesToKey(Encoding.UTF8.GetBytes(_cryptoKey), salt, 32, 16); using var aes = Aes.Create(); aes.Mode = CipherMode.ECB; aes.Padding = PaddingMode.PKCS7; aes.Key = key; using var encryptor = aes.CreateEncryptor(); byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext); byte[] ciphertext = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length); byte[] salted = Encoding.ASCII.GetBytes("Salted__"); byte[] combined = new byte[salted.Length + salt.Length + ciphertext.Length]; Buffer.BlockCopy(salted, 0, combined, 0, salted.Length); Buffer.BlockCopy(salt, 0, combined, salted.Length, salt.Length); Buffer.BlockCopy(ciphertext, 0, combined, salted.Length + salt.Length, ciphertext.Length); string base64 = Convert.ToBase64String(combined); return base64.Replace('+', '-').Replace('/', '_').TrimEnd('='); } // OpenSSL's EVP_BytesToKey with MD5 (CryptoJS's default OpenSSLKdf) — iteratively hashes // (previous digest + password + salt) until enough key+IV bytes are produced. private static (byte[] Key, byte[] Iv) EvpBytesToKey(byte[] password, byte[] salt, int keyLen, int ivLen) { byte[] result = Array.Empty(); byte[] prev = Array.Empty(); using var md5 = MD5.Create(); while (result.Length < keyLen + ivLen) { byte[] input = new byte[prev.Length + password.Length + salt.Length]; Buffer.BlockCopy(prev, 0, input, 0, prev.Length); Buffer.BlockCopy(password, 0, input, prev.Length, password.Length); Buffer.BlockCopy(salt, 0, input, prev.Length + password.Length, salt.Length); prev = md5.ComputeHash(input); byte[] newResult = new byte[result.Length + prev.Length]; Buffer.BlockCopy(result, 0, newResult, 0, result.Length); Buffer.BlockCopy(prev, 0, newResult, result.Length, prev.Length); result = newResult; } return (result[..keyLen], result[keyLen..(keyLen + ivLen)]); } } }