From fd2e46ca911ef115afba7e52b501156fc1a37be9 Mon Sep 17 00:00:00 2001 From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:10:20 -0400 Subject: [PATCH 1/2] fix(upstream): read fort from disabledHooks, and stop the second config call PoracleNG 5.2.1 adds `fort` to `disabledHooks` (jfberry/PoracleNG#197, filed as #195), so the separate `general.disable_fort_update` read from /api/config/values exists only for older servers now. `PoracleDisabledHookMap` gains the `fort` entry, and `UpstreamFeatureFlagService` makes the fort probe conditional. The discriminator is the presence of `availableLanguages` on the config response, not a version number: both fields arrived in the same release, PoracleNG serves no version endpoint, and an empty `disabledHooks` cannot tell "nothing is disabled" from "too old to say". Verified live -- absent on 5.1.0, present and null on 5.2.1. A config read that failed leaves the discriminator unanswered, so the probe is still made. Guessing "new" there would silently stop honouring the flag on every older server the moment Poracle hiccuped. Everything else still fails open. `pokestop` is dropped from the array upstream in the same release; the mapping stays deliberately empty for the older servers that still send it. --- CHANGELOG.md | 1 + CLAUDE.md | 6 +- .../PoracleConfig.cs | 30 +++++++++ .../PoracleDisabledHookMap.cs | 36 +++++----- .../PoracleApiProxy.cs | 20 ++++++ .../UpstreamFeatureFlagService.cs | 48 +++++++++++--- .../PoracleApiProxyDisableFlagTests.cs | 44 +++++++++++++ .../UpstreamFeatureFlagServiceTests.cs | 66 +++++++++++++++++++ 8 files changed, 221 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4dae93..76d26189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **A Poracle that switches fort changes off is now heard from the same place as every other switched-off alarm type.** PoracleNG left fort changes out of the list of disabled types it publishes, so this site had to make a second call to a different endpoint on every check to find out. PoracleNG 5.2.1 puts it in the list, and that second call is now made only against a server old enough to still need it -- recognised by what its configuration response carries, not by its version, and made anyway if that response cannot be read. Nothing changes about which types you see: an operator who has switched fort changes off in Poracle's own config gets the same answer either way ([jfberry/PoracleNG#195](https://github.com/jfberry/PoracleNG/issues/195)). - **Nine human, location and place operations now use PoracleNG's `/api/v2` surface, keeping the old one as a fallback.** Nothing on screen changes and there is no new version requirement: a server without `/api/v2` gets byte-identical requests to the ones it gets today. What changes is that the site stops writing to Poracle's `humans` table directly. Changing your alert language was the last direct write left, and it now goes through Poracle -- which means the change reaches Poracle's running process immediately, rather than waiting for the next restart to be noticed. - **Nothing on screen: the last code that could write an alarm straight to Poracle's database has been taken out.** Every alarm write has gone through PoracleNG's API since 2.0, so its deduplication, its field defaults and its immediate state reload all run -- but the database tables were still mapped in code beside it, one line away from being used again. That mapping is gone, along with a set of profile methods nothing had called since the same migration. The two places that still reach the alarm tables directly are unchanged and are there for reasons written down beside them. - **A refused alarm explains itself the same way whichever Poracle surface answered.** The v2 write path already read PoracleNG's newer RFC 9457 error bodies and named the individual field it refused; the older v1 path, still the one most installs use, was reading only the older shape and answering a validation refusal as though the server had broken. Both paths now share one reader, and where many fields are refused at once the message names the first few and counts the rest rather than rendering a dozen clauses into a snackbar ([#803](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/803)). diff --git a/CLAUDE.md b/CLAUDE.md index b39e4cc1..4b860d2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -306,9 +306,11 @@ Deliberately **not** gated: `/api/auth/me` under `disable_profiles`, so the JWT `disable_geomap` and `disable_geomap_select` were removed from the admin UI and from `SettingsMigrationService` in the same change. They are legacy PoracleJS keys describing a map picker PoracleWeb does not have, so there was nothing to wire them to and inventing a meaning would have been worse than deleting them. Any rows left in `site_settings` are harmless -- nothing reads them. `disable_userlist` was never a toggle in this UI (the migration carries `admin_disable_userlist` as a legacy key only). -**Poracle's own flags are a floor under these (#769).** `UpstreamFeatureFlagService` reads `disabledHooks` from `/api/config/poracleWeb` plus `general.disable_fort_update` from `/api/config/values`, maps them to `disable_*` keys via `PoracleDisabledHookMap`, and `FeatureGate` treats a type as off if **either** source disables it. Cached 5 min. It **fails open**: any fault, timeout or absent field yields an empty set, because a Poracle outage disabling every alarm type for everyone is worse than the problem being solved. `GET /api/settings/upstream-disabled` exposes the resolved keys so nav, route guards and the admin toggles agree with the API. +**Poracle's own flags are a floor under these (#769).** `UpstreamFeatureFlagService` reads `disabledHooks` from `/api/config/poracleWeb`, plus `general.disable_fort_update` from `/api/config/values` on a server too old to report `fort` in the array, maps them to `disable_*` keys via `PoracleDisabledHookMap`, and `FeatureGate` treats a type as off if **either** source disables it. Cached 5 min. It **fails open**: any fault, timeout or absent field yields an empty set, because a Poracle outage disabling every alarm type for everyone is worse than the problem being solved. `GET /api/settings/upstream-disabled` exposes the resolved keys so nav, route guards and the admin toggles agree with the API. -Two traps, both verified against 5.1.0 and both load-bearing: `pokestop` is in `disabledHooks` but `DisablePokestop` has no consumer in the processor, so it maps to **nothing** — mapping it to lures/invasions/quests would disable three working types; and `disable_fort_update` is enforced upstream but omitted from the array, which is the only reason the second config call exists. Both filed upstream (jfberry/PoracleNG#195). +Two traps, both verified against 5.1.0, both filed upstream as jfberry/PoracleNG#195 and both fixed in 5.2.1 (jfberry/PoracleNG#197). `pokestop` was in `disabledHooks` while `DisablePokestop` had no consumer in the processor, so it maps to **nothing** — mapping it to lures/invasions/quests would disable three working types. It is gone from the array now, and the mapping stays empty for the older servers that still send it. `disable_fort_update` was enforced upstream but omitted from the array; `fort` is in it as of 5.2.1 and maps like any other hook. + +The second `/api/config/values` read for `disable_fort_update` survives for those older servers only, and **`availableLanguages` is how one is recognised** — not a version number. Both landed in the same release, the field's presence is unambiguous where an empty `disabledHooks` is not (nothing disabled, or too old to say?), and PoracleNG serves no version endpoint. A config read that failed answers the question with nothing, so the probe is made rather than skipped: assuming "new" there would stop honouring the flag on every older server the moment Poracle hiccuped. **Adding a new alarm type? Wire it through all four layers:** diff --git a/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs b/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs index 5e395c3a..d3bf757c 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs @@ -72,6 +72,36 @@ public List? DisabledHooks get; set; } + /// + /// The exact set of language codes Poracle will accept for a human's alert language, as reported by + /// availableLanguages on GET /api/config/poracleWeb. + /// + /// + /// null means unrestricted — any code is accepted. Upstream reports null for an unset and an + /// empty map alike, because its own write path only validates a non-empty one, so the two are + /// genuinely the same answer. Use , not this, to tell whether + /// the server said anything at all. Added in PoracleNG 5.2.1 (jfberry/PoracleNG#197). + /// + public List? AvailableLanguages + { + get; set; + } + + /// + /// True when the response carried an availableLanguages field, whatever its value. + /// + /// + /// Doubles as this application's "is the server 5.2.1 or later" test. The field arrived in the same + /// release that added fort to disabledHooks, and unlike the hook array its presence is + /// unambiguous: an empty disabledHooks could mean either "nothing is disabled" or "too old to + /// say", whereas an absent field can only mean the latter. See + /// UpstreamFeatureFlagService.ProbeAsync. + /// + public bool ReportsAvailableLanguages + { + get; set; + } + public PoracleAdmins? Admins { get; set; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/PoracleDisabledHookMap.cs b/Core/Pgan.PoracleWebNet.Core.Models/PoracleDisabledHookMap.cs index 72c1aa16..af876ab9 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/PoracleDisabledHookMap.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/PoracleDisabledHookMap.cs @@ -14,29 +14,25 @@ namespace Pgan.PoracleWebNet.Core.Models; /// agree with the two surfaces that already do. See #769. /// /// -/// Two entries in the upstream array deliberately map to nothing: +/// weather maps to nothing because PoracleWeb.NET has no weather alarms. So does +/// pokestop, which looks like the parent hook for lures, invasions and quests but was +/// vestigial: DisablePokestop appeared nowhere in the 5.1.0 processor outside the array +/// itself, and mapping it would have taken three working types away for a flag that did nothing. +/// PoracleNG 5.2.1 dropped it from the array and marked the config field deprecated +/// (jfberry/PoracleNG#197), so it now only reaches here from an older server — where ignoring it is +/// still the right answer. /// -/// -/// -/// pokestop looks like the parent hook for lures, invasions and quests, but -/// DisablePokestop appears nowhere in the PoracleNG 5.1.0 processor outside the -/// disabledHooks list itself. Mapping it would take three working alarm types away from any -/// server that sets a flag which currently does nothing. -/// -/// -/// weather has no counterpart because PoracleWeb has no weather alarms. -/// -/// /// -/// disable_fort_update is the mirror-image case: PoracleNG honours it in both the processor -/// and the bot, but omits it from the hookTypes list, so it never appears in -/// disabledHooks. It is read separately from general.disable_fort_update on -/// GET /api/config/values — see IPoracleApiProxy.GetFortUpdateDisabledAsync. +/// fort was the mirror-image case up to 5.1.0: enforced in the processor and the bot but left +/// out of hookTypes, so it had to be read separately from general.disable_fort_update +/// on GET /api/config/values. The same upstream release added it to the array under the name +/// its tracking type already used, so it maps here like any other hook and the second read is now +/// made only for a server too old to report it — see UpstreamFeatureFlagService.ProbeAsync. /// /// -/// disable_showcase is the same shape and has deliberately no entry here either: verified on -/// a live 5.2.1, it is present in general on GET /api/config/values and absent from -/// disabledHooks. See IPoracleApiProxy.GetShowcaseDisabledAsync. +/// disable_showcase still has deliberately no entry: verified on a live 5.2.1, it is present in +/// general on GET /api/config/values and absent from disabledHooks. See +/// IPoracleApiProxy.GetShowcaseDisabledAsync. /// /// public static class PoracleDisabledHookMap @@ -59,6 +55,8 @@ public static IReadOnlyDictionary ByHookName ["nest"] = DisableFeatureKeys.Nests, ["gym"] = DisableFeatureKeys.Gyms, ["maxbattle"] = DisableFeatureKeys.MaxBattles, + // Reported from PoracleNG 5.2.1 on. Older servers say so only via general.disable_fort_update. + ["fort"] = DisableFeatureKeys.FortChanges, }; /// diff --git a/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs b/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs index a8d9fbb4..c0e0e780 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs @@ -143,6 +143,26 @@ public class PoracleApiProxy(HttpClient httpClient, IConfiguration configuration } } + if (root.TryGetProperty("availableLanguages", out var availableLanguages)) + { + // Presence is the signal, value is the restriction. Absent means a PoracleNG older than + // 5.2.1; present-and-null means present-and-unrestricted, which upstream reports for an + // unset and an empty map alike. + config.ReportsAvailableLanguages = true; + + if (availableLanguages.ValueKind == JsonValueKind.Array) + { + config.AvailableLanguages = []; + foreach (var code in availableLanguages.EnumerateArray()) + { + if (code.ValueKind == JsonValueKind.String && code.GetString() is { Length: > 0 } value) + { + config.AvailableLanguages.Add(value); + } + } + } + } + if (root.TryGetProperty("admins", out var admins)) { config.Admins = new PoracleAdmins(); diff --git a/Core/Pgan.PoracleWebNet.Core.Services/UpstreamFeatureFlagService.cs b/Core/Pgan.PoracleWebNet.Core.Services/UpstreamFeatureFlagService.cs index 465b47d2..757f620d 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/UpstreamFeatureFlagService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/UpstreamFeatureFlagService.cs @@ -10,11 +10,12 @@ namespace Pgan.PoracleWebNet.Core.Services; /// /// /// -/// Two upstream reads are needed because the flags are split across two shapes. The -/// disabledHooks array on GET /api/config/poracleWeb covers the nine webhook types in -/// PoracleNG's hookTypes list; general.disable_fort_update on -/// GET /api/config/values covers fort changes, which PoracleNG enforces in the processor and -/// the bot but leaves out of the array. +/// The disabledHooks array on GET /api/config/poracleWeb carries the flags. Up to +/// PoracleNG 5.1.0 it left out fort changes, which the processor and the bot enforced from +/// general.disable_fort_update, so that value had to be fetched separately from +/// GET /api/config/values. PoracleNG 5.2.1 put fort in the array +/// (jfberry/PoracleNG#197) and the extra read is now made only against a server old enough to need +/// it — see for how one is recognised. /// /// /// The result is cached server-wide for five minutes, matching SiteSettingService. Upstream @@ -59,6 +60,7 @@ public async Task> GetDisabledKeysAsync() private async Task> ProbeAsync() { var keys = new HashSet(StringComparer.Ordinal); + var hookListCarriesFort = false; try { @@ -67,6 +69,8 @@ private async Task> ProbeAsync() { keys.Add(key); } + + hookListCarriesFort = config?.ReportsAvailableLanguages == true; } catch (Exception ex) { @@ -77,6 +81,36 @@ private async Task> ProbeAsync() // not exist on an older server. } + if (!hookListCarriesFort) + { + await this.ProbeFortUpdateAsync(keys); + } + + await this.ProbePokestopEventsAsync(keys); + + return keys; + } + + /// + /// Reads general.disable_fort_update, the only place a PoracleNG older than 5.2.1 reports + /// fort changes being switched off. + /// + /// + /// + /// Skipped entirely when the config response carried availableLanguages, which is the + /// discriminator rather than a version string: both arrived in the same release, the field's + /// presence is unambiguous where an empty disabledHooks is not (nothing disabled, or too old + /// to say?), and PoracleNG serves no version endpoint worth parsing. Verified live — absent on + /// 5.1.0, present and null on 5.2.1. + /// + /// + /// A config read that failed leaves the discriminator unanswered, so the probe is made rather than + /// skipped: guessing "new" there would silently stop honouring the flag on every older server the + /// moment Poracle hiccuped. + /// + /// + private async Task ProbeFortUpdateAsync(HashSet keys) + { try { if (await this._poracleApiProxy.GetFortUpdateDisabledAsync() == true) @@ -90,10 +124,6 @@ private async Task> ProbeAsync() // we already have. PoracleJS does not serve that route at all. LogProbeFailed(this._logger, "general.disable_fort_update", ex); } - - await this.ProbePokestopEventsAsync(keys); - - return keys; } /// diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleApiProxyDisableFlagTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleApiProxyDisableFlagTests.cs index f43170e0..f9f8aa01 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleApiProxyDisableFlagTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleApiProxyDisableFlagTests.cs @@ -95,6 +95,50 @@ public async Task AbsentFortUpdateFlagReturnsNull() Assert.Null(await sut.GetFortUpdateDisabledAsync()); } + // --- availableLanguages: absent, null and a list are three different answers --- + + /// + /// Verified against a live 5.1.0: the key is absent. That absence is the only signal telling this + /// application it is talking to a server that leaves fort out of disabledHooks. + /// + [Fact] + public async Task AbsentAvailableLanguagesMarksTheServerAsNotReportingThem() + { + var sut = CreateSut(new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"locale":"en"}""")); + + var config = await sut.GetConfigAsync(); + + Assert.False(config?.ReportsAvailableLanguages); + Assert.Null(config?.AvailableLanguages); + } + + /// + /// Verified against a live 5.2.1: present and null, meaning unrestricted. Upstream reports null for + /// both an unset and an empty map, because its own write path only validates a non-empty one. + /// + [Fact] + public async Task NullAvailableLanguagesReportsTheFieldButRestrictsNothing() + { + var sut = CreateSut(new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"availableLanguages":null}""")); + + var config = await sut.GetConfigAsync(); + + Assert.True(config?.ReportsAvailableLanguages); + Assert.Null(config?.AvailableLanguages); + } + + [Fact] + public async Task AvailableLanguagesArrayIsParsedAsTheExhaustiveAllowList() + { + var sut = CreateSut(new MockHttpMessageHandler( + HttpStatusCode.OK, /*lang=json,strict*/ """{"availableLanguages":["en","de","pt-BR"]}""")); + + var config = await sut.GetConfigAsync(); + + Assert.True(config?.ReportsAvailableLanguages); + Assert.Equal(["en", "de", "pt-BR"], config?.AvailableLanguages); + } + [Fact] public async Task QuestSummaryFlagStillReadsFromTheTrackingSection() { diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/UpstreamFeatureFlagServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UpstreamFeatureFlagServiceTests.cs index ea4d934b..cdf5e2aa 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/UpstreamFeatureFlagServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UpstreamFeatureFlagServiceTests.cs @@ -29,6 +29,17 @@ private UpstreamFeatureFlagService CreateSut() => private void UpstreamHooks(params string[] hooks) => this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync(new PoracleConfig { DisabledHooks = [.. hooks] }); + /// + /// A 5.2.1-or-later server: it reports availableLanguages, which is how this class tells a + /// server that lists fort in disabledHooks from one that does not. + /// + private void UpstreamModern(params string[] hooks) => + this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync(new PoracleConfig + { + DisabledHooks = [.. hooks], + ReportsAvailableLanguages = true, + }); + /// /// What prod serves. An empty array is a positive statement that nothing is disabled upstream, /// and must leave every type enabled rather than being read as "no data, assume the worst". @@ -127,6 +138,61 @@ public async Task FailedFortUpdateReadKeepsTheHooksAlreadyResolved() Assert.Equal([DisableFeatureKeys.Lures], await this.CreateSut().GetDisabledKeysAsync()); } + // --- fort: reported in disabledHooks from 5.2.1 on --- + + /// + /// The upstream fix. fort joined hookTypes in PoracleNG 5.2.1, named to match the + /// tracking type, so the flag arrives in the array like every other one. + /// + [Fact] + public async Task FortInDisabledHooksDisablesFortChanges() + { + this.UpstreamHooks("fort"); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(false); + + Assert.Equal([DisableFeatureKeys.FortChanges], await this.CreateSut().GetDisabledKeysAsync()); + } + + /// + /// A server that reports availableLanguages also reports fort, so the second + /// /api/config/values round-trip has nothing left to tell us and is not made. + /// + [Fact] + public async Task ServerReportingAvailableLanguagesIsNotAskedForTheFortFlag() + { + this.UpstreamModern(); + + Assert.Empty(await this.CreateSut().GetDisabledKeysAsync()); + this._proxy.Verify(p => p.GetFortUpdateDisabledAsync(), Times.Never); + } + + /// + /// The legitimate case the discriminator exists to protect: 5.1.0 omits fort from the array + /// and only general.disable_fort_update knows, so the probe must still be made. + /// + [Fact] + public async Task ServerWithoutAvailableLanguagesIsStillAskedForTheFortFlag() + { + this.UpstreamHooks(); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(true); + + Assert.Equal([DisableFeatureKeys.FortChanges], await this.CreateSut().GetDisabledKeysAsync()); + this._proxy.Verify(p => p.GetFortUpdateDisabledAsync(), Times.Once); + } + + /// + /// A config read that failed says nothing about the server's age, so the older-server probe is + /// still made rather than skipped on an assumption. + /// + [Fact] + public async Task UnreadableConfigStillAsksForTheFortFlag() + { + this._proxy.Setup(p => p.GetConfigAsync()).ThrowsAsync(new HttpRequestException("connection refused")); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(true); + + Assert.Equal([DisableFeatureKeys.FortChanges], await this.CreateSut().GetDisabledKeysAsync()); + } + // --- degradation: the site settings must stay in sole charge --- /// From 3736c32522f2fd65e19cfc30751c8c65f99cad76 Mon Sep 17 00:00:00 2001 From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:18:34 -0400 Subject: [PATCH 2/2] feat(i18n): offer only the alert languages Poracle will accept PoracleNG 5.2.1 publishes `availableLanguages` on /api/config/poracleWeb (jfberry/PoracleNG#197, filed as #194): the exact set of codes it will accept for a human's language, answering 422 to anything else. Until now the alert language menu offered all eleven regardless, so on a restricted server some rows were writes that could only fail. It governs the alert language, not the display language. `allowed_languages` is this site's own restriction on the display menu and stays independent -- the two answer to different owners, and neither substitutes for the other. Served to the SPA as `poracle_alert_languages`, a projection in the same shape as `poracle_locale`: refused by `Upsert` and declared in `PROJECTED_KEYS`, so a stored row cannot pin a list Poracle has stopped agreeing with. No row is served when Poracle restricts nothing -- absent, null and empty all mean unrestricted, which is what an unconfigured 5.2.1 and every 5.1.0 send, and both keep the full menu. The menu item hides itself when Poracle accepts nothing this UI has a flag row for, rather than opening on an empty list. `load()` still reconciles against every language shipped, so a stored value predating a restriction is reported rather than lost. --- .../Controllers/SettingsController.cs | 117 ++++++++++++------ .../ClientApp/src/app/app.html | 12 +- .../ClientApp/src/app/app.spec.ts | 15 ++- .../ClientApp/src/app/app.ts | 5 + .../services/alert-language.service.spec.ts | 42 +++++++ .../core/services/alert-language.service.ts | 43 ++++++- .../modules/admin/admin-settings.component.ts | 2 +- .../admin/admin-settings.groups.spec.ts | 4 + CHANGELOG.md | 1 + CLAUDE.md | 8 ++ .../Controllers/SettingsControllerTests.cs | 113 +++++++++++++++++ docs/features/internationalization.md | 6 +- 12 files changed, 320 insertions(+), 48 deletions(-) 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) { + + }