Created
September 30, 2025 08:19
-
-
Save wezz/a58880b6b27ea609057b9541a92d1266 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Generic; | |
| using Microsoft.Data.SqlClient; | |
| using System.Data.Common; | |
| using System.IO; | |
| using System.Linq; | |
| using System.Text; | |
| using System.Threading.Tasks; | |
| using Addon.Episerver.EnvironmentSynchronizer.Configuration; | |
| using EPiServer.ServiceLocation; | |
| using Microsoft.AspNetCore.Hosting; | |
| using Microsoft.Extensions.Configuration; | |
| using Microsoft.Extensions.Logging; | |
| using EPiServer.Data; | |
| namespace Addon.Episerver.EnvironmentSynchronizer.Synchronizers; | |
| [ServiceConfiguration(typeof(IEnvironmentSynchronizer))] | |
| public class GetaRedirectDomainEnvironmentSynchronizer( | |
| ILogger<GetaRedirectDomainEnvironmentSynchronizer> logger, | |
| IWebHostEnvironment webHostEnvironment, | |
| IConfiguration configuration) : IEnvironmentSynchronizer | |
| { | |
| private const string ProductionEnvironment = "Production"; | |
| private const string EnvironmentSynchronizerSection = "EnvironmentSynchronizer"; | |
| private const string SiteDefinitionsSection = "SiteDefinitions"; | |
| private static readonly string[] ExcludedFilePatterns = ["-", "_"]; | |
| public async Task<string> SynchronizeAsync(string environmentName) | |
| { | |
| // Skip execution in production environment | |
| if (IsProductionEnvironment(environmentName)) | |
| { | |
| logger.LogInformation("Skipping redirect domain synchronization in production environment"); | |
| return string.Empty; | |
| } | |
| try | |
| { | |
| var domainMapping = await BuildDomainMappingAsync(); | |
| var domainReplacements = BuildDomainReplacements(domainMapping); | |
| return await ProcessRedirectsAsync(domainReplacements); | |
| } | |
| catch (Exception ex) | |
| { | |
| logger.LogError(ex, "Failed to synchronize redirect domains for environment: {EnvironmentName}", environmentName); | |
| throw; | |
| } | |
| } | |
| // Keep synchronous version for interface compatibility | |
| public string Synchronize(string environmentName) | |
| { | |
| return SynchronizeAsync(environmentName).GetAwaiter().GetResult(); | |
| } | |
| private static bool IsProductionEnvironment(string environmentName) => | |
| string.Equals(environmentName, ProductionEnvironment, StringComparison.OrdinalIgnoreCase); | |
| private async Task<Dictionary<string, Dictionary<string, string>>> BuildDomainMappingAsync() | |
| { | |
| var domainMapping = new Dictionary<string, Dictionary<string, string>>(); | |
| var appSettingsFiles = GetAppSettingsFiles(); | |
| var tasks = appSettingsFiles.Select(async file => | |
| { | |
| var environmentName = ExtractEnvironmentName(file); | |
| var config = await LoadConfigurationFromFileAsync(file); | |
| var environmentSynchronizerConfig = config.GetSection(EnvironmentSynchronizerSection); | |
| if (!environmentSynchronizerConfig.Exists()) | |
| return; | |
| var siteDefinitions = environmentSynchronizerConfig | |
| .GetSection(SiteDefinitionsSection) | |
| .Get<List<SiteDefinitionOptions>>(); | |
| if (siteDefinitions == null) | |
| return; | |
| foreach (var siteDefinition in siteDefinitions.Where(sd => !string.IsNullOrEmpty(sd.Id))) | |
| { | |
| lock (domainMapping) | |
| { | |
| domainMapping.TryAdd(siteDefinition.Id, []); | |
| } | |
| // Add main site URL | |
| if (!string.IsNullOrEmpty(siteDefinition.SiteUrl)) | |
| { | |
| lock (domainMapping) | |
| { | |
| domainMapping[siteDefinition.Id][siteDefinition.SiteUrl] = environmentName; | |
| } | |
| } | |
| // Add host definitions | |
| var validHosts = siteDefinition.Hosts | |
| .Where(host => !string.IsNullOrEmpty(host.Name) && host.Name != "*") | |
| .Select(host => new | |
| { | |
| Protocol = host.UseSecureConnection ? "https://" : "http://", | |
| HostName = host.Name | |
| }) | |
| .Select(host => $"{host.Protocol}{host.HostName}/"); | |
| foreach (var fullUrl in validHosts) | |
| { | |
| lock (domainMapping) | |
| { | |
| domainMapping[siteDefinition.Id][fullUrl] = environmentName; | |
| } | |
| } | |
| } | |
| }); | |
| await Task.WhenAll(tasks); | |
| return domainMapping; | |
| } | |
| private List<string> GetAppSettingsFiles() | |
| { | |
| var contentRoot = webHostEnvironment.ContentRootPath; | |
| return Directory.GetFiles(contentRoot, "appsettings.*.json") | |
| .Where(f => !Array.Exists(ExcludedFilePatterns, pattern => Path.GetFileName(f).Contains(pattern))) | |
| .ToList(); | |
| } | |
| private static string ExtractEnvironmentName(string filePath) | |
| { | |
| var fileName = Path.GetFileNameWithoutExtension(filePath); | |
| return fileName.Replace("appsettings.", "").Replace(".json", ""); | |
| } | |
| private static async Task<IConfiguration> LoadConfigurationFromFileAsync(string filePath) | |
| { | |
| var builder = new ConfigurationBuilder() | |
| .SetBasePath(Path.GetDirectoryName(filePath)!) | |
| .AddJsonFile(Path.GetFileName(filePath), optional: false, reloadOnChange: false); | |
| return await Task.FromResult(builder.Build()); | |
| } | |
| private Dictionary<string, string> BuildDomainReplacements(Dictionary<string, Dictionary<string, string>> domainMapping) | |
| { | |
| var replacements = new Dictionary<string, string>(); | |
| var currentEnvironment = GetCurrentEnvironmentName(); | |
| logger.LogInformation("Building domain replacements for current environment: {CurrentEnvironment}", currentEnvironment); | |
| logger.LogInformation("Total site definitions in domain mapping: {SiteDefinitionCount}", domainMapping.Count); | |
| foreach (var (siteId, siteDomains) in domainMapping) | |
| { | |
| logger.LogInformation("Processing site {SiteId} with {DomainCount} domains", siteId, siteDomains.Count); | |
| // Find the current environment's domain for this site | |
| var currentDomain = siteDomains.FirstOrDefault(kvp => kvp.Value == currentEnvironment).Key; | |
| logger.LogInformation("Current domain for site {SiteId}: {CurrentDomain}", siteId, currentDomain ?? "NOT FOUND"); | |
| if (currentDomain is null || !IsValidDomainUrl(currentDomain)) | |
| { | |
| logger.LogWarning("No valid current domain found for site {SiteId}", siteId); | |
| continue; | |
| } | |
| logger.LogInformation("Found valid current domain for site {SiteId}: {CurrentDomain}", siteId, currentDomain); | |
| // Add ALL other domains for this site as replacements (including Production) | |
| foreach (var (domainUrl, environment) in siteDomains) | |
| { | |
| logger.LogInformation("Checking domain: {DomainUrl} (environment: {Environment})", domainUrl, environment); | |
| if (environment == currentEnvironment || !IsValidDomainUrl(domainUrl)) | |
| { | |
| logger.LogInformation("Skipped domain {DomainUrl} - Same environment or invalid URL", domainUrl); | |
| continue; | |
| } | |
| logger.LogInformation("Domain {DomainUrl} passed URL validation", domainUrl); | |
| // Extract domain part (remove protocol and trailing slash) | |
| var sourceDomain = ExtractDomainFromUrl(domainUrl); | |
| var targetDomain = ExtractDomainFromUrl(currentDomain); | |
| logger.LogInformation("Extracted domains - Source: {SourceDomain}, Target: {TargetDomain}", | |
| sourceDomain ?? "NULL", targetDomain ?? "NULL"); | |
| // Only add if both domains are valid | |
| if (string.IsNullOrEmpty(sourceDomain) || string.IsNullOrEmpty(targetDomain)) | |
| { | |
| logger.LogWarning("Skipped null domain replacement: {DomainUrl} -> {CurrentDomain}", domainUrl, currentDomain); | |
| continue; | |
| } | |
| // Additional safety check - ensure source domain is not just a protocol | |
| if (!IsValidDomainForReplacement(sourceDomain) || !IsValidDomainForReplacement(targetDomain)) | |
| { | |
| logger.LogWarning("Skipped invalid domain replacement: {SourceDomain} → {TargetDomain} (from {Environment} environment)", | |
| sourceDomain, targetDomain, environment); | |
| logger.LogWarning("Source domain valid: {SourceValid}, Target domain valid: {TargetValid}", | |
| IsValidDomainForReplacement(sourceDomain), IsValidDomainForReplacement(targetDomain)); | |
| continue; | |
| } | |
| replacements[sourceDomain] = targetDomain; | |
| logger.LogInformation("Added replacement: {SourceDomain} → {TargetDomain} (from {Environment} environment)", | |
| sourceDomain, targetDomain, environment); | |
| } | |
| } | |
| logger.LogInformation("Total domain replacements built: {ReplacementCount}", replacements.Count); | |
| return replacements; | |
| } | |
| private async Task<string> ProcessRedirectsAsync(Dictionary<string, string> domainReplacements) | |
| { | |
| if (!domainReplacements.Any()) | |
| { | |
| logger.LogInformation("No domain replacements to process"); | |
| return "No domain replacements to process"; | |
| } | |
| var resultLog = new StringBuilder(); | |
| var totalUpdated = 0; | |
| var connectionString = configuration.GetConnectionString(new DataAccessOptions().DefaultConnectionStringName); | |
| if (string.IsNullOrEmpty(connectionString)) | |
| { | |
| throw new InvalidOperationException("Database connection string not found"); | |
| } | |
| using var connection = new SqlConnection(connectionString); | |
| await connection.OpenAsync(); | |
| using var transaction = await connection.BeginTransactionAsync(); | |
| try | |
| { | |
| // Process each domain replacement with SQL REPLACE | |
| foreach (var (sourceDomain, targetDomain) in domainReplacements) | |
| { | |
| // Update OldUrl | |
| var oldUrlCount = await UpdateRedirectUrlsAsync(connection, transaction, sourceDomain, targetDomain, "OldUrl"); | |
| logger.LogInformation("Updated {OldUrlCount} OldUrl records: {SourceDomain} → {TargetDomain}", | |
| oldUrlCount, sourceDomain, targetDomain); | |
| resultLog.AppendLine($"Updated {oldUrlCount} OldUrl records: {sourceDomain} → {targetDomain}"); | |
| // Update NewUrl | |
| var newUrlCount = await UpdateRedirectUrlsAsync(connection, transaction, sourceDomain, targetDomain, "NewUrl"); | |
| logger.LogInformation("Updated {NewUrlCount} NewUrl records: {SourceDomain} → {TargetDomain}", | |
| newUrlCount, sourceDomain, targetDomain); | |
| resultLog.AppendLine($"Updated {newUrlCount} NewUrl records: {sourceDomain} → {targetDomain}"); | |
| totalUpdated += oldUrlCount + newUrlCount; | |
| } | |
| await transaction.CommitAsync(); | |
| resultLog.AppendLine($"Domain replacement completed successfully. Total records updated: {totalUpdated}"); | |
| logger.LogInformation("Domain replacement completed successfully. Total records updated: {TotalUpdated}", totalUpdated); | |
| } | |
| catch (Exception ex) | |
| { | |
| await transaction.RollbackAsync(); | |
| var errorMessage = $"Error updating redirects: {ex.Message}"; | |
| resultLog.AppendLine(errorMessage); | |
| logger.LogError(ex, "Failed to update redirects"); | |
| throw; | |
| } | |
| return resultLog.ToString(); | |
| } | |
| private static async Task<int> UpdateRedirectUrlsAsync(SqlConnection connection, DbTransaction transaction, | |
| string sourceDomain, string targetDomain, string columnName) | |
| { | |
| const string updateQuery = """ | |
| UPDATE [dbo].[NotFoundHandler.Redirects] | |
| SET {0} = REPLACE({0}, @SourceDomain, @TargetDomain) | |
| WHERE {0} LIKE '%' + @SourceDomain + '%' | |
| """; | |
| using var command = new SqlCommand(string.Format(updateQuery, columnName), connection, (SqlTransaction)transaction); | |
| command.Parameters.AddWithValue("@SourceDomain", sourceDomain); | |
| command.Parameters.AddWithValue("@TargetDomain", targetDomain); | |
| return await command.ExecuteNonQueryAsync(); | |
| } | |
| private static bool IsValidDomainUrl(string url) => | |
| !string.IsNullOrEmpty(url) && | |
| url.Length >= 9 && | |
| !url.Contains('*') && | |
| url.Contains('/') && | |
| url.Contains("http"); | |
| private static bool IsValidDomainForReplacement(string domain) => | |
| !string.IsNullOrEmpty(domain) && | |
| domain.Length >= 3 && | |
| !domain.Equals("http", StringComparison.OrdinalIgnoreCase) && | |
| !domain.Equals("https", StringComparison.OrdinalIgnoreCase) && | |
| !domain.StartsWith('.') && !domain.EndsWith('.') && | |
| !domain.StartsWith('-') && !domain.EndsWith('-') && | |
| !domain.Any(c => c is ' ' or '\t' or '\n'); | |
| private static string ExtractDomainFromUrl(string url) | |
| { | |
| try | |
| { | |
| var uri = new Uri(url); | |
| var domain = uri.Host + (uri.IsDefaultPort ? "" : $":{uri.Port}"); | |
| // Additional validation to ensure we have a proper domain | |
| if (string.IsNullOrEmpty(uri.Host) || uri.Host.Length < 3) | |
| { | |
| return string.Empty; | |
| } | |
| return domain; | |
| } | |
| catch (UriFormatException) | |
| { | |
| return string.Empty; | |
| } | |
| } | |
| private string GetCurrentEnvironmentName() => webHostEnvironment.EnvironmentName; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment