Skip to content

Instantly share code, notes, and snippets.

@wezz
Created September 30, 2025 08:19
Show Gist options
  • Select an option

  • Save wezz/a58880b6b27ea609057b9541a92d1266 to your computer and use it in GitHub Desktop.

Select an option

Save wezz/a58880b6b27ea609057b9541a92d1266 to your computer and use it in GitHub Desktop.
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