diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs index 4ef28886..ceab1c27 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs @@ -40,8 +40,9 @@ public partial class SettingsController( // only -- the one group that least needs it -- so an admin configuring it saw it work and had no // way to tell it was invisible to everyone else. See #513. "custom_page_name", "custom_page_url", "custom_page_icon", - // Poracle's own locale, synthesized rather than stored -- see GetPoracleLocaleAsync. - PoracleLocaleKey, + // Poracle's own locale and its alert-language allow-list, synthesized rather than stored -- + // see GetPoracleProjectionsAsync. + PoracleLocaleKey, PoracleAlertLanguagesKey, }; /// @@ -66,7 +67,19 @@ public partial class SettingsController( /// internal const string PoracleLocaleKey = "poracle_locale"; - private const string PoracleLocaleCacheKey = "settings:poracle_locale"; + /// + /// Pseudo-setting carrying the language codes Poracle will accept for a human's alert + /// language, comma-separated, from availableLanguages on GET /api/config/poracleWeb. + /// + /// + /// Absent when Poracle restricts nothing — which covers both an unrestricted 5.2.1 and any server + /// too old to report the field — so the SPA reads an absent value as "offer everything". Nothing to + /// do with allowed_languages, which is this site's own restriction on the display + /// language; the two govern different menus and neither substitutes for the other. + /// + internal const string PoracleAlertLanguagesKey = "poracle_alert_languages"; + + private const string PoracleProjectionsCacheKey = "settings:poracle_projections"; /// Matches the shape of a locale tag (de, pt-BR, zh-cn) and nothing else. [GeneratedRegex("^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$")] @@ -97,7 +110,7 @@ public async Task GetAll() settings = settings.Where(s => IsUserVisible(s.Key)); } - return this.Ok(await this.WithPoracleLocaleAsync(settings)); + return this.Ok(await this.WithPoracleProjectionsAsync(settings)); } /// True when a non-admin may read . @@ -152,7 +165,7 @@ public async Task GetCostumeCapability() public async Task GetPublic() { var publicSettings = await this._siteSettingService.GetPublicAsync(); - return this.Ok(await this.WithPoracleLocaleAsync(publicSettings)); + return this.Ok(await this.WithPoracleProjectionsAsync(publicSettings)); } [HttpGet("discord-config")] @@ -252,14 +265,15 @@ public async Task Upsert(string key, [FromBody] SiteSettingReques }); } - // poracle_locale is a projection of Poracle's config, not a row this page owns. Nothing stopped - // it being written, and because a real row wins over the synthesized value, one accidental save + // Both of these are projections of Poracle's config, not rows this page owns. Nothing stopped + // them being written, and because a real row wins over the synthesized value, one accidental save // would have pinned the language default forever and silently stopped tracking Poracle. See #780. - if (string.Equals(key, PoracleLocaleKey, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(key, PoracleLocaleKey, StringComparison.OrdinalIgnoreCase) + || string.Equals(key, PoracleAlertLanguagesKey, StringComparison.OrdinalIgnoreCase)) { return this.BadRequest(new { - error = "poracle_locale is read from Poracle's configuration and cannot be set here." + error = $"{key} is read from Poracle's configuration and cannot be set here." }); } @@ -304,58 +318,87 @@ public async Task Upsert(string key, [FromBody] SiteSettingReques } /// - /// Appends the Poracle locale pseudo-setting to , unless a real row of the - /// same key already exists -- an admin-set value wins over what Poracle reports. + /// Appends the two Poracle pseudo-settings to , each unless a real row of + /// the same key already exists -- an admin-set value wins over what Poracle reports. /// - private async Task> WithPoracleLocaleAsync(IEnumerable settings) + private async Task> WithPoracleProjectionsAsync(IEnumerable settings) { var list = settings.ToList(); - if (list.Exists(s => string.Equals(s.Key, PoracleLocaleKey, StringComparison.OrdinalIgnoreCase))) - { - return list; - } + var (locale, alertLanguages) = await this.GetPoracleProjectionsAsync(); - var locale = await this.GetPoracleLocaleAsync(); - if (!string.IsNullOrEmpty(locale)) + Project(list, PoracleLocaleKey, locale); + Project(list, PoracleAlertLanguagesKey, alertLanguages); + + return list; + } + + private static void Project(List list, string key, string? value) + { + if (string.IsNullOrEmpty(value) + || list.Exists(s => string.Equals(s.Key, key, StringComparison.OrdinalIgnoreCase))) { - list.Add(new SiteSetting - { - Key = PoracleLocaleKey, - Value = locale, - Category = "branding", - ValueType = "string", - }); + return; } - return list; + list.Add(new SiteSetting + { + Key = key, + Value = value, + Category = "branding", + ValueType = "string", + }); } /// - /// Reads locale from Poracle's config, cached for five minutes. Both the settings endpoints that - /// serve it are hit on every page load, and one of them is anonymous, so an uncached read would put a - /// PoracleNG roundtrip in front of the login page. A Poracle outage caches a null and the SPA keeps its - /// existing stored/browser/en ordering -- the locale is a nicety, never a blocker. + /// Reads Poracle's locale and its alert-language allow-list from one config call, cached for + /// five minutes. Both the settings endpoints that serve them are hit on every page load, and one of + /// them is anonymous, so an uncached read would put a PoracleNG roundtrip in front of the login page. + /// A Poracle outage caches two nulls: the SPA keeps its existing stored/browser/en ordering + /// and offers the full alert-language menu, both of which are what an unrestricted server would give + /// anyway. Neither value is ever a blocker. /// - private async Task GetPoracleLocaleAsync() + private async Task<(string? Locale, string? AlertLanguages)> GetPoracleProjectionsAsync() { - if (this._cache.TryGetValue(PoracleLocaleCacheKey, out var cached)) + if (this._cache.TryGetValue<(string?, string?)>(PoracleProjectionsCacheKey, out var cached)) { return cached; } - string? locale = null; + (string? Locale, string? AlertLanguages) projections = (null, null); try { var config = await this._poracleApiProxy.GetConfigAsync(); - locale = NormalizeLocale(config?.Locale); + projections = (NormalizeLocale(config?.Locale), NormalizeAlertLanguages(config?.AvailableLanguages)); } catch (Exception ex) { LogFetchLocaleFailed(this._logger, ex); } - this._cache.Set(PoracleLocaleCacheKey, locale, TimeSpan.FromMinutes(5)); - return locale; + this._cache.Set(PoracleProjectionsCacheKey, projections, TimeSpan.FromMinutes(5)); + return projections; + } + + /// + /// Renders Poracle's availableLanguages as a comma-separated list, or null when it restricts + /// nothing. Null upstream means unrestricted -- an unset and an empty map both report it, because + /// Poracle's own write path only validates a non-empty one -- and so does an absent field, which is + /// what a server older than 5.2.1 sends. All three are the same answer here: no row, full menu. + /// Individual codes are shape-checked and dropped rather than the whole list being discarded. + /// + internal static string? NormalizeAlertLanguages(IEnumerable? availableLanguages) + { + if (availableLanguages is null) + { + return null; + } + + var codes = availableLanguages + .Select(NormalizeLocale) + .Where(c => !string.IsNullOrEmpty(c)) + .ToList(); + + return codes.Count == 0 ? null : string.Join(',', codes); } /// @@ -370,7 +413,7 @@ private async Task> WithPoracleLocaleAsync(IEnumerable - + @if (alertLanguage.languages().length > 0) { + + }