Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 80 additions & 37 deletions Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

/// <summary>
Expand All @@ -66,7 +67,19 @@ public partial class SettingsController(
/// </summary>
internal const string PoracleLocaleKey = "poracle_locale";

private const string PoracleLocaleCacheKey = "settings:poracle_locale";
/// <summary>
/// Pseudo-setting carrying the language codes Poracle will accept for a human's <em>alert</em>
/// language, comma-separated, from <c>availableLanguages</c> on <c>GET /api/config/poracleWeb</c>.
/// </summary>
/// <remarks>
/// 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 <c>allowed_languages</c>, which is this site's own restriction on the <em>display</em>
/// language; the two govern different menus and neither substitutes for the other.
/// </remarks>
internal const string PoracleAlertLanguagesKey = "poracle_alert_languages";

private const string PoracleProjectionsCacheKey = "settings:poracle_projections";

/// <summary>Matches the shape of a locale tag (<c>de</c>, <c>pt-BR</c>, <c>zh-cn</c>) and nothing else.</summary>
[GeneratedRegex("^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$")]
Expand Down Expand Up @@ -97,7 +110,7 @@ public async Task<IActionResult> GetAll()
settings = settings.Where(s => IsUserVisible(s.Key));
}

return this.Ok(await this.WithPoracleLocaleAsync(settings));
return this.Ok(await this.WithPoracleProjectionsAsync(settings));
}

/// <summary>True when a non-admin may read <paramref name="key"/>.</summary>
Expand Down Expand Up @@ -152,7 +165,7 @@ public async Task<IActionResult> GetCostumeCapability()
public async Task<IActionResult> GetPublic()
{
var publicSettings = await this._siteSettingService.GetPublicAsync();
return this.Ok(await this.WithPoracleLocaleAsync(publicSettings));
return this.Ok(await this.WithPoracleProjectionsAsync(publicSettings));
}

[HttpGet("discord-config")]
Expand Down Expand Up @@ -252,14 +265,15 @@ public async Task<IActionResult> 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."
});
}

Expand Down Expand Up @@ -304,58 +318,87 @@ public async Task<IActionResult> Upsert(string key, [FromBody] SiteSettingReques
}

/// <summary>
/// Appends the Poracle locale pseudo-setting to <paramref name="settings"/>, 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 <paramref name="settings"/>, each unless a real row of
/// the same key already exists -- an admin-set value wins over what Poracle reports.
/// </summary>
private async Task<List<SiteSetting>> WithPoracleLocaleAsync(IEnumerable<SiteSetting> settings)
private async Task<List<SiteSetting>> WithPoracleProjectionsAsync(IEnumerable<SiteSetting> 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<SiteSetting> 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",
});
}

/// <summary>
/// Reads <c>locale</c> 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/<c>en</c> ordering -- the locale is a nicety, never a blocker.
/// Reads Poracle's <c>locale</c> 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/<c>en</c> 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.
/// </summary>
private async Task<string?> GetPoracleLocaleAsync()
private async Task<(string? Locale, string? AlertLanguages)> GetPoracleProjectionsAsync()
{
if (this._cache.TryGetValue<string?>(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;
}

/// <summary>
/// Renders Poracle's <c>availableLanguages</c> 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.
/// </summary>
internal static string? NormalizeAlertLanguages(IEnumerable<string>? 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);
}

/// <summary>
Expand All @@ -370,7 +413,7 @@ private async Task<List<SiteSetting>> WithPoracleLocaleAsync(IEnumerable<SiteSet
return !string.IsNullOrEmpty(trimmed) && LocalePattern().IsMatch(trimmed) ? trimmed : null;
}

[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to read Poracle's configured locale")]
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to read Poracle's configuration for the settings projections")]
private static partial void LogFetchLocaleFailed(ILogger logger, Exception ex);

public class SiteSettingRequest
Expand Down
12 changes: 7 additions & 5 deletions Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,12 @@
}
<!-- Two language controls that do different jobs. Adjacent and distinctly labelled, the
difference is visible at a glance; apart, each one looked like "the" language setting. -->
<button mat-menu-item [matMenuTriggerFor]="alertLanguageMenu">
<mat-icon>forum</mat-icon>
<span>{{ 'MENU.ALERT_LANGUAGE' | translate }}</span>
</button>
@if (alertLanguage.languages().length > 0) {
<button mat-menu-item [matMenuTriggerFor]="alertLanguageMenu">
<mat-icon>forum</mat-icon>
<span>{{ 'MENU.ALERT_LANGUAGE' | translate }}</span>
</button>
}
<mat-divider></mat-divider>
<button mat-menu-item (click)="logout()">
<mat-icon>logout</mat-icon>
Expand Down Expand Up @@ -190,7 +192,7 @@

<mat-menu #alertLanguageMenu="matMenu" class="language-menu-panel">
<p class="alert-language-hint">{{ 'MENU.ALERT_LANGUAGE_HINT' | translate }}</p>
@for (lang of alertLanguage.languages; track lang.code) {
@for (lang of alertLanguage.languages(); track lang.code) {
<button mat-menu-item (click)="chooseAlertLanguage(lang.code)" class="language-menu-item">
<img class="lang-flag" [src]="'assets/flags/' + lang.countryCode + '.svg'" [alt]="lang.name" />
<span class="lang-name">{{ lang.name }}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ describe('App bootstrap language defaults (#770)', () => {
const loadOnce = jest.fn(() => of([]));
const loadPublic = jest.fn(() => of([]));
const init = jest.fn();
const alertLanguage = { languages: [], load: jest.fn(), selected: signal('en') };
const alertLanguage = { languages: signal([]), load: jest.fn(), restrictTo: jest.fn(), selected: signal('en') };

TestBed.resetTestingModule();
TestBed.configureTestingModule({
Expand Down Expand Up @@ -238,6 +238,19 @@ describe('App bootstrap language defaults (#770)', () => {
expect(init).toHaveBeenLastCalledWith(undefined, undefined);
});

it("forwards Poracle's alert-language allow-list to the alert language menu", () => {
const { alertLanguage } = setup({ authenticated: true, settings: { poracle_alert_languages: 'en,de' } });

expect(alertLanguage.restrictTo).toHaveBeenLastCalledWith('en,de');
});

it('restricts nothing when Poracle is too old to report an allow-list', () => {
// 5.1.0 sends no availableLanguages, so the key is absent and every language stays on offer.
const { alertLanguage } = setup({ authenticated: true, settings: { poracle_locale: 'en' } });

expect(alertLanguage.restrictTo).toHaveBeenLastCalledWith(undefined);
});

it('does not reconcile the alert language while signed out (#775)', () => {
// GET /api/location/language is [Authorize]. Calling it here guaranteed a 401 on every login-page
// visit; LocationService swallowing the error is what kept it invisible.
Expand Down
5 changes: 5 additions & 0 deletions Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,11 @@ export class App implements OnInit {
// have been the obvious source for the locale and is [Authorize] -- see #426.
const settings = this.settingsService.siteSettings();
this.i18n.init(settings['allowed_languages'], settings['poracle_locale']);
// A different restriction on a different menu: allowed_languages is this site's own list for the
// display language, poracle_alert_languages is what Poracle will accept for the alert language.
// Absent means unrestricted -- both from a Poracle that restricts nothing and from one too old
// to report the field.
this.alertLanguage.restrictTo(settings['poracle_alert_languages']);
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,46 @@ describe('AlertLanguageService', () => {
expect(alert.selected()).toBe('fr');
expect(store['poracle-language']).toBe('fr');
});
describe('the languages Poracle will accept', () => {
it('should offer all eleven when Poracle restricts nothing', () => {
const { alert, i18n } = create();
i18n.init();
alert.restrictTo(undefined);

expect(alert.languages().length).toBe(i18n.allLanguages.length);
});

it('should offer all eleven on a Poracle too old to say', () => {
// 5.1.0 has no availableLanguages field, so the settings response carries no such key at all.
const { alert, i18n } = create();
i18n.init();

expect(alert.languages().length).toBe(i18n.allLanguages.length);
});

it('should offer only what Poracle accepts when it is restricted', () => {
const { alert, i18n } = create();
i18n.init();
alert.restrictTo('en,de,ja');

// ja is Poracle's to offer and not this UI's to render -- there is no flag row for it.
expect(alert.languages().map(l => l.code)).toEqual(['en', 'de']);
});

it('should match case-insensitively, because Poracle keeps its own casing', () => {
const { alert, i18n } = create();
i18n.init();
alert.restrictTo('EN,pt-br');

expect(alert.languages().map(l => l.code)).toEqual(['en', 'pt-BR']);
});

it('should offer nothing when Poracle accepts nothing this UI ships', () => {
const { alert, i18n } = create();
i18n.init();
alert.restrictTo('ja,ru');

expect(alert.languages()).toEqual([]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,26 @@ export class AlertLanguageService {

private readonly locationService = inject(LocationService);

/** Every language Poracle can write alerts in. */
readonly languages = this.i18n.allLanguages;
/**
* The codes Poracle will accept, lower-cased. Empty means unrestricted, which covers a Poracle that
* restricts nothing and one too old to have an opinion -- both accept any code.
*/
private readonly poracleCodes = signal<string[]>([]);

/**
* The languages that can actually be picked: those this UI has a flag row for, and that Poracle will
* accept for `humans.language`.
*
* Poracle validates a set language against its own `available_languages` and answers 422 for anything
* outside it, so offering more than this would be offering a write that fails. Deliberately *not*
* filtered by `allowed_languages`, which is this site's own restriction on the display language and
* has nothing to say about what Poracle writes DMs in.
*/
readonly languages = computed(() => {
const accepted = this.poracleCodes();
if (accepted.length === 0) return this.i18n.allLanguages;
return this.i18n.allLanguages.filter(l => accepted.includes(l.code.toLowerCase()));
});

/**
* The language Poracle will actually write in, or null when we cannot tell.
Expand Down Expand Up @@ -69,12 +87,31 @@ export class AlertLanguageService {
// on both API versions and from the bot's own !language command, so humans.language for a
// Brazilian Portuguese user reads back as 'pt-br' while the code here is 'pt-BR'. An exact
// comparison dropped it silently and the picker fell back to the server default.
const known = language ? this.languages.find(l => l.code.toLowerCase() === language.toLowerCase()) : undefined;
// Against every language this UI ships, not the narrowed menu: this is recognising what Poracle
// already stored, which can predate a restriction, and losing it would report the wrong language
// rather than a disallowed one.
const known = language ? this.i18n.allLanguages.find(l => l.code.toLowerCase() === language.toLowerCase()) : undefined;
if (known) {
this.chosen.set(known.code);
localStorage.setItem(STORAGE_KEY, known.code);
}
},
});
}

/**
* Narrows the menu to the languages Poracle accepts, from `availableLanguages` on its config.
*
* @param codes comma-separated, or undefined/empty for unrestricted. Both an unrestricted server and
* one older than PoracleNG 5.2.1 send nothing, and both accept any code, so the two need no telling
* apart here.
*/
restrictTo(codes: string | undefined): void {
this.poracleCodes.set(
(codes ?? '')
.split(',')
.map(c => c.trim().toLowerCase())
.filter(Boolean),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ interface SettingGroup {
* synthesized value, so one save pins it forever and stops tracking Poracle. Writes are refused
* server-side too; this only keeps the box off the page. See #780.
*/
export const PROJECTED_KEYS = ['poracle_locale'];
export const PROJECTED_KEYS = ['poracle_locale', 'poracle_alert_languages'];

const RETIRED_KEYS = [
// Legacy Poracle keys describing a map picker this app does not have. Removed from the settings UI and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ describe('PROJECTED_KEYS', () => {
expect(PROJECTED_KEYS).toContain('poracle_locale');
});

it("covers poracle_alert_languages, which is Poracle's list and not this page's to edit", () => {
expect(PROJECTED_KEYS).toContain('poracle_alert_languages');
});

it('declares nothing that is also a real, editable setting', () => {
const editable = new Set(SETTING_GROUPS.flatMap(g => g.settings.map(s => s.key)));
const overlap = PROJECTED_KEYS.filter(k => editable.has(k));
Expand Down
Loading
Loading