Last active
June 17, 2026 06:16
-
-
Save AndyButland/08645809ed46287f15aa5557044a4ecc to your computer and use it in GitHub Desktop.
Sample controller that can be used to change an Umbraco language from one ISO code to another (see https://github.com/umbraco/Umbraco-CMS/issues/20674). Use at your own risk and with care (backup database before using).
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
| // --------------------------------------------------------------------------------------------------- | |
| // Change a language's ISO code (culture) and re-align the data that references it by culture string. | |
| // | |
| // Issue: https://github.com/umbraco/Umbraco-CMS/issues/20674 | |
| // Version: Written against Umbraco 17.5. | |
| // Access: Gated by a shared secret. Set a long random value in configuration and send the same value | |
| // in the X-Maintenance-Key header; the endpoint returns 401 unless the key is configured AND | |
| // the header matches it: | |
| // "Umbraco": { CMS: { "Maintenance": { "ChangeLanguageCultureKey": "<long-random-secret>" } } } | |
| // Delete this file once the fix has been applied. | |
| // Usage: Drop into a project (adjust the namespace), take a database backup, take the site offline, | |
| // then POST (header X-Maintenance-Key: <secret>): | |
| // /maintenance/change-language-culture?oldIso=da-DK&newIso=en-GB | |
| // Returns a plain-text report of what was changed. Rename only - newIso must not already exist. | |
| // Scale: Suitable for small-to-medium sites. Property values are processed in bounded batches and | |
| // progress is written to the logger, but the whole job runs synchronously in one request and | |
| // one transaction. On a large site the request may exceed an HTTP/proxy timeout - pass | |
| // rebuild=false to skip the (usually time-consuming) cache/Examine rebuilds and run them separately, | |
| // or move this logic into a migration step or background task. | |
| // --------------------------------------------------------------------------------------------------- | |
| using System.Globalization; | |
| using System.Text; | |
| using System.Text.Json; | |
| using System.Text.Json.Nodes; | |
| using Microsoft.AspNetCore.Mvc; | |
| using Microsoft.Extensions.Logging; | |
| using Umbraco.Cms.Core; | |
| using Umbraco.Cms.Core.Models; | |
| using Umbraco.Cms.Core.PublishedCache; | |
| using Umbraco.Cms.Core.Services; | |
| using Umbraco.Cms.Core.Services.OperationStatus; | |
| using Umbraco.Cms.Infrastructure.Examine; | |
| using Umbraco.Cms.Infrastructure.Scoping; | |
| using Umbraco.Cms.Infrastructure.Serialization; | |
| namespace Umbraco.Cms.Web.UI.Controllers; | |
| /// <summary> | |
| /// One-off maintenance workaround for issue #20674: changes a language's culture (ISO code) and | |
| /// re-aligns the culture strings that block editors persist inside their JSON (the <c>expose</c> | |
| /// array and each variant block value), which are otherwise orphaned because they reference the | |
| /// language by ISO string rather than by id. Also repairs redirect-URL cultures and, unless | |
| /// <c>rebuild=false</c> is passed, rebuilds the published cache and Examine indexes. | |
| /// | |
| /// Rename only: the new ISO code must not already be assigned to another language (a true swap of | |
| /// two existing languages is intentionally out of scope). Take a database backup and run with the | |
| /// site offline before invoking. | |
| /// | |
| /// Gated by a shared secret: set the configuration key <c>Umbraco:Maintenance:ChangeLanguageCultureKey</c> | |
| /// and pass the same value in the <c>X-Maintenance-Key</c> request header. The endpoint is inert until the | |
| /// secret is configured, and is exposed as a POST so it cannot be triggered by a cross-site GET. Audit | |
| /// entries are attributed to the super user. Delete this controller once the fix is applied. | |
| /// </summary> | |
| public class ChangeLanguageCultureController : Controller | |
| { | |
| private static readonly string[] _blockEditorAliases = | |
| { | |
| Constants.PropertyEditors.Aliases.BlockList, | |
| Constants.PropertyEditors.Aliases.BlockGrid, | |
| Constants.PropertyEditors.Aliases.RichText, | |
| }; | |
| // Block values are loaded and rewritten one bounded batch at a time so a large site doesn't | |
| // materialise every matching row (each holding potentially large JSON) at once. Kept well under | |
| // Constants.Sql.MaxParameterCount for the WHERE id IN (...) detail load. | |
| private const int RowProcessingBatchSize = 500; | |
| // Match the encoder Umbraco uses for block values by calling the same factory Umbraco registers | |
| // (DefaultJsonSerializerEncoderFactory) with the block-value serializer type (SystemTextJsonSerializer), | |
| // rather than duplicating its literal. Re-serialized JSON then keeps byte-for-byte identical escaping - | |
| // '<' and non-ASCII characters (e.g. å, æ, ø) are emitted as \uXXXX escapes, exactly as Umbraco stores | |
| // them - so untouched rows are not rewritten, and this stays correct if Umbraco changes its default. | |
| private static readonly JsonSerializerOptions _jsonOptions = new() | |
| { | |
| Encoder = new DefaultJsonSerializerEncoderFactory().CreateEncoder<SystemTextJsonSerializer>(), | |
| }; | |
| private readonly ILanguageService _languageService; | |
| private readonly IContentTypeService _contentTypeService; | |
| private readonly IMediaTypeService _mediaTypeService; | |
| private readonly IMemberTypeService _memberTypeService; | |
| private readonly IScopeProvider _scopeProvider; | |
| private readonly IDatabaseCacheRebuilder _databaseCacheRebuilder; | |
| private readonly IIndexRebuilder _indexRebuilder; | |
| private readonly IConfiguration _configuration; | |
| private readonly ILogger<ChangeLanguageCultureController> _logger; | |
| public ChangeLanguageCultureController( | |
| ILanguageService languageService, | |
| IContentTypeService contentTypeService, | |
| IMediaTypeService mediaTypeService, | |
| IMemberTypeService memberTypeService, | |
| IScopeProvider scopeProvider, | |
| IDatabaseCacheRebuilder databaseCacheRebuilder, | |
| IIndexRebuilder indexRebuilder, | |
| IConfiguration configuration, | |
| ILogger<ChangeLanguageCultureController> logger) | |
| { | |
| _languageService = languageService; | |
| _contentTypeService = contentTypeService; | |
| _mediaTypeService = mediaTypeService; | |
| _memberTypeService = memberTypeService; | |
| _scopeProvider = scopeProvider; | |
| _databaseCacheRebuilder = databaseCacheRebuilder; | |
| _indexRebuilder = indexRebuilder; | |
| _configuration = configuration; | |
| _logger = logger; | |
| } | |
| [HttpPost("/maintenance/change-language-culture")] | |
| public async Task<IActionResult> Run(string oldIso, string newIso, bool rebuild = true, CancellationToken cancellationToken = default) | |
| { | |
| var configuredKey = _configuration["Umbraco:CMS:Maintenance:ChangeLanguageCultureKey"]; | |
| if (string.IsNullOrWhiteSpace(configuredKey) | |
| || string.Equals(Request.Headers["X-Maintenance-Key"], configuredKey, StringComparison.Ordinal) is false) | |
| { | |
| return Unauthorized(); | |
| } | |
| var report = new StringBuilder(); | |
| // Mirror every report line to the logger so progress is visible server-side even if the HTTP | |
| // response is never received (e.g. a proxy times out the long-running request). | |
| void Log(string message) | |
| { | |
| report.AppendLine(message); | |
| _logger.LogInformation("[ChangeLanguageCulture] {Message}", message); | |
| } | |
| if (string.IsNullOrWhiteSpace(oldIso) || string.IsNullOrWhiteSpace(newIso)) | |
| { | |
| return BadRequest("Provide both 'oldIso' and 'newIso' query parameters, e.g. /maintenance/change-language-culture?oldIso=da-DK&newIso=en-GB"); | |
| } | |
| if (oldIso.Equals(newIso, StringComparison.OrdinalIgnoreCase)) | |
| { | |
| return BadRequest("'oldIso' and 'newIso' must differ."); | |
| } | |
| if (IsValidCulture(newIso) is false) | |
| { | |
| return BadRequest($"'{newIso}' is not a recognised culture."); | |
| } | |
| ILanguage? language = await _languageService.GetAsync(oldIso); | |
| if (language is null) | |
| { | |
| return BadRequest($"No language found with ISO code '{oldIso}'."); | |
| } | |
| if (await _languageService.GetAsync(newIso) is not null) | |
| { | |
| return BadRequest($"ISO code '{newIso}' is already assigned to another language. Swapping two existing languages is not supported by this workaround."); | |
| } | |
| // ISO lookups are case-insensitive, so the stored casing may differ from what was supplied. | |
| // Work from the stored value: every culture string we rewrite (block JSON, redirect URLs) is | |
| // persisted with the language's exact ISO casing, so an exact/ordinal match must use it too. | |
| oldIso = language.IsoCode; | |
| var scanned = 0; | |
| var rewritten = 0; | |
| int redirectsUpdated; | |
| int domainsUsingLanguage; | |
| // Rename and data fix-up share a single scope so a failure part-way through rolls back the | |
| // rename too, rather than leaving the language renamed but its stored cultures unrepaired. | |
| // The language service opens its own nested scope, which the ambient outer scope controls. | |
| using (IScope scope = _scopeProvider.CreateScope()) | |
| { | |
| // Rename the language first so the subsequent cache rebuild resolves the new culture name. | |
| language.IsoCode = newIso; | |
| language.CultureName = CultureInfo.GetCultureInfo(newIso).DisplayName; | |
| Attempt<ILanguage, LanguageOperationStatus> updateAttempt = | |
| await _languageService.UpdateAsync(language, Constants.Security.SuperUserKey); | |
| if (updateAttempt.Success is false) | |
| { | |
| // Scope disposed without Complete - the rename rolls back. | |
| return BadRequest($"Failed to update the language: {updateAttempt.Status}."); | |
| } | |
| Log($"Renamed language id {language.Id}: '{oldIso}' -> '{newIso}'."); | |
| List<int> propertyTypeIds = GetBlockEditorPropertyTypeIds(); | |
| Log($"Found {propertyTypeIds.Count} block-editor property type(s) to scan."); | |
| Log($"Note: only the core block aliases are scanned ({string.Join(", ", _blockEditorAliases)}); custom property editors that store culture-keyed JSON under other aliases are not touched."); | |
| // Collect the ids of candidate rows first (ints only, so cheap to hold), then load and rewrite | |
| // the large textValue payloads one bounded batch at a time rather than materialising them all. | |
| // The LIKE filter stays deliberately broad ('%oldIso%'): a nested block value is stored as an | |
| // escaped JSON string (\"culture\":\"da-DK\"), which a tighter "culture":"..." match would miss. | |
| // False-positive matches are harmless - RewriteCultures leaves non-culture occurrences unchanged. | |
| var candidateIds = new List<int>(); | |
| foreach (IEnumerable<int> typeIdGroup in propertyTypeIds.InGroupsOf(Constants.Sql.MaxParameterCount)) | |
| { | |
| candidateIds.AddRange(scope.Database.Fetch<int>( | |
| "SELECT id FROM umbracoPropertyData WHERE textValue IS NOT NULL AND textValue LIKE @likePattern AND propertyTypeId IN (@ids)", | |
| new { likePattern = "%" + oldIso + "%", ids = typeIdGroup.ToArray() })); | |
| } | |
| Log($"Candidate block property values to inspect: {candidateIds.Count}."); | |
| foreach (IEnumerable<int> idGroup in candidateIds.InGroupsOf(RowProcessingBatchSize)) | |
| { | |
| // Stop cleanly between batches if the caller disconnects; the scope is disposed without | |
| // Complete, so the rename and any updates so far roll back together. | |
| cancellationToken.ThrowIfCancellationRequested(); | |
| List<PropertyDataRow> rows = scope.Database.Fetch<PropertyDataRow>( | |
| "SELECT id AS Id, textValue AS TextValue FROM umbracoPropertyData WHERE id IN (@ids)", | |
| new { ids = idGroup.ToArray() }); | |
| foreach (PropertyDataRow row in rows) | |
| { | |
| scanned++; | |
| if (row.TextValue is null) | |
| { | |
| continue; | |
| } | |
| var newValue = RewriteCultures(row.TextValue, oldIso, newIso, out var changed); | |
| if (changed is false) | |
| { | |
| continue; | |
| } | |
| scope.Database.Execute( | |
| "UPDATE umbracoPropertyData SET textValue = @0 WHERE id = @1", | |
| newValue, | |
| row.Id); | |
| rewritten++; | |
| } | |
| Log($"Progress: inspected {scanned}/{candidateIds.Count} candidate values, rewritten {rewritten} so far."); | |
| } | |
| redirectsUpdated = scope.Database.Execute( | |
| "UPDATE umbracoRedirectUrl SET culture = @0 WHERE culture = @1", | |
| newIso, | |
| oldIso); | |
| domainsUsingLanguage = scope.Database.ExecuteScalar<int>( | |
| "SELECT COUNT(*) FROM umbracoDomain WHERE domainDefaultLanguage = @0", | |
| language.Id); | |
| scope.Complete(); | |
| } | |
| Log($"Block property values scanned: {scanned}, rewritten (all versions): {rewritten}."); | |
| Log($"Redirect URL rows updated: {redirectsUpdated}."); | |
| Log($"Domains assigned to this language (follow the rename automatically via languageId): {domainsUsingLanguage}."); | |
| if (rebuild) | |
| { | |
| await _databaseCacheRebuilder.RebuildAsync(useBackgroundThread: false); | |
| Log("Published cache rebuilt."); | |
| await _indexRebuilder.RebuildIndexesAsync(onlyEmptyIndexes: false, delay: null, useBackgroundThread: false); | |
| Log("Examine indexes rebuilt."); | |
| } | |
| else | |
| { | |
| Log("Skipped cache and Examine rebuilds (rebuild=false). Rebuild the published cache and Examine indexes manually before bringing the site back online."); | |
| } | |
| Log("Done."); | |
| return Content(report.ToString(), "text/plain"); | |
| } | |
| private List<int> GetBlockEditorPropertyTypeIds() | |
| { | |
| var aliases = new HashSet<string>(_blockEditorAliases, StringComparer.Ordinal); | |
| var ids = new HashSet<int>(); | |
| IEnumerable<IContentTypeComposition> allTypes = _contentTypeService.GetAll() | |
| .Cast<IContentTypeComposition>() | |
| .Concat(_mediaTypeService.GetAll()) | |
| .Concat(_memberTypeService.GetAll()); | |
| foreach (IContentTypeComposition type in allTypes) | |
| { | |
| foreach (IPropertyType propertyType in type.PropertyTypes) | |
| { | |
| if (aliases.Contains(propertyType.PropertyEditorAlias)) | |
| { | |
| ids.Add(propertyType.Id); | |
| } | |
| } | |
| } | |
| return ids.ToList(); | |
| } | |
| private static string RewriteCultures(string json, string oldIso, string newIso, out bool changed) | |
| { | |
| changed = false; | |
| JsonNode? node; | |
| try | |
| { | |
| node = JsonNode.Parse(json); | |
| } | |
| catch | |
| { | |
| return json; | |
| } | |
| if (node is null) | |
| { | |
| return json; | |
| } | |
| changed = Walk(node, oldIso, newIso); | |
| // Only re-serialize when something actually changed; an unchanged row is left byte-for-byte | |
| // intact rather than being rewritten in a (potentially differently-escaped) round-trip. | |
| return changed ? node.ToJsonString(_jsonOptions) : json; | |
| } | |
| private static bool Walk(JsonNode node, string oldIso, string newIso) | |
| { | |
| var changed = false; | |
| switch (node) | |
| { | |
| case JsonObject obj: | |
| foreach (var key in obj.Select(kvp => kvp.Key).ToArray()) | |
| { | |
| if (obj[key] is not JsonNode child) | |
| { | |
| continue; | |
| } | |
| if (child is JsonValue value && value.TryGetValue(out string? text) && text is not null) | |
| { | |
| if (key == "culture" && string.Equals(text, oldIso, StringComparison.OrdinalIgnoreCase)) | |
| { | |
| obj[key] = newIso; | |
| changed = true; | |
| } | |
| else if (LooksLikeBlockJson(text)) | |
| { | |
| // Nested block editors store their inner block value as a JSON string, so the | |
| // recursive walk can't reach it directly - re-parse, rewrite, and store back. | |
| var rewritten = RewriteCultures(text, oldIso, newIso, out var nestedChanged); | |
| if (nestedChanged) | |
| { | |
| obj[key] = rewritten; | |
| changed = true; | |
| } | |
| } | |
| } | |
| else | |
| { | |
| changed |= Walk(child, oldIso, newIso); | |
| } | |
| } | |
| break; | |
| case JsonArray array: | |
| foreach (JsonNode? item in array) | |
| { | |
| if (item is not null) | |
| { | |
| changed |= Walk(item, oldIso, newIso); | |
| } | |
| } | |
| break; | |
| } | |
| return changed; | |
| } | |
| private static bool LooksLikeBlockJson(string value) | |
| { | |
| var trimmed = value.TrimStart(); | |
| return trimmed.StartsWith('{') | |
| && (value.Contains("\"expose\"", StringComparison.Ordinal) | |
| || value.Contains("\"contentData\"", StringComparison.Ordinal)); | |
| } | |
| private static bool IsValidCulture(string isoCode) | |
| { | |
| try | |
| { | |
| CultureInfo.GetCultureInfo(isoCode); | |
| return true; | |
| } | |
| catch (CultureNotFoundException) | |
| { | |
| return false; | |
| } | |
| } | |
| private sealed record PropertyDataRow | |
| { | |
| public int Id { get; init; } | |
| public string? TextValue { get; init; } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment