From b9ea837c75542adc012c345526bd533672c7cdcc Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 15 Oct 2026 23:04:40 +0200 Subject: [PATCH] fix(app): treat a black instance theme colour as unset (#3502) --- .../src/api/instance/InstanceConfigRepository.ts | 9 +-------- .../app/state/InstanceSnapshotStore.test.ts | 15 +++++++++++++++ .../features/app/state/RuntimeConfigSnapshot.ts | 3 ++- .../features/theme/utils/InstanceBrandColors.ts | 14 +++----------- packages/instance_bootstrap/src/ThemeColor.ts | 13 +++++++++++++ 5 files changed, 34 insertions(+), 20 deletions(-) create mode 100644 packages/instance_bootstrap/src/ThemeColor.ts diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.ts index d2774b993..4fd6cd6e7 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -57,6 +57,7 @@ import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {resolveSmtpTlsMode, type SmtpTlsMode, smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants'; import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError'; import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; +import {normalizeThemeColor} from '@fluxer/instance_bootstrap/src/ThemeColor'; import { InstanceConfigResponse, InstanceConfigUpdateRequest, @@ -414,14 +415,6 @@ function normalizeOptionalPublicString(value: string | null | undefined, fallbac return value === undefined ? fallback : normalizeOptionalString(value); } -function normalizeThemeColor(value: unknown): string | null { - const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(normalizeOptionalString(value) ?? ''); - if (match === null) return null; - const digits = match[1].toLowerCase(); - const color = `#${digits.length === 3 ? digits.replace(/./g, '$&$&') : digits}`; - return color === '#000000' ? null : color; -} - function getDefaultPremiumProductName(): string { return Config.instance.selfHosted ? 'Premium' : 'Plutonium'; } diff --git a/fluxer_app/src/features/app/state/InstanceSnapshotStore.test.ts b/fluxer_app/src/features/app/state/InstanceSnapshotStore.test.ts index 966ce5df1..50c31ec31 100644 --- a/fluxer_app/src/features/app/state/InstanceSnapshotStore.test.ts +++ b/fluxer_app/src/features/app/state/InstanceSnapshotStore.test.ts @@ -270,6 +270,21 @@ describe('runtimeSnapshotFromDiscovery', () => { ).toThrow(/appPublic\.branding\.icon_url/u); }); + test('treats a black or malformed theme colour as unset and normalises the rest', () => { + const snapshot = runtimeSnapshotFromDiscovery(parsedDocument('t.test')); + const themeColorOf = (theme_color: unknown) => + requireRuntimeConfigSnapshot({ + ...snapshot, + appPublic: {...snapshot.appPublic, branding: {...snapshot.appPublic.branding, theme_color}}, + }).appPublic.branding.theme_color; + for (const unset of ['#000000', '#000', '000000', '', 'rgb(1, 2, 3)', 'rebeccapurple', 0, null]) { + expect(themeColorOf(unset)).toBeNull(); + } + expect(themeColorOf('#FF0000')).toBe('#ff0000'); + expect(themeColorOf(' f00 ')).toBe('#ff0000'); + expect(themeColorOf('#000001')).toBe('#000001'); + }); + test('carries the advertised domain migration', () => { const domainMigration = { enabled: true, diff --git a/fluxer_app/src/features/app/state/RuntimeConfigSnapshot.ts b/fluxer_app/src/features/app/state/RuntimeConfigSnapshot.ts index 97d332481..9432607bb 100644 --- a/fluxer_app/src/features/app/state/RuntimeConfigSnapshot.ts +++ b/fluxer_app/src/features/app/state/RuntimeConfigSnapshot.ts @@ -14,6 +14,7 @@ import { } from '@fluxer/instance_bootstrap/src/Discovery'; import {InstanceEndpointKind, normalizeInstanceEndpoint} from '@fluxer/instance_bootstrap/src/EndpointNormalization'; import {CanonicalNetworkProtocol} from '@fluxer/instance_bootstrap/src/NetworkOrigin'; +import {normalizeThemeColor} from '@fluxer/instance_bootstrap/src/ThemeColor'; import { AGE_POLICY_ACTIONS, type InstanceAgePolicy, @@ -413,7 +414,7 @@ function readAppPublic(value: unknown): InstanceAppPublic { logo_url: readNullableAbsoluteHttpUrl(branding, 'logo_url', 'appPublic.branding.logo_url'), wordmark_url: readNullableAbsoluteHttpUrl(branding, 'wordmark_url', 'appPublic.branding.wordmark_url'), favicon_url: readNullableAbsoluteHttpUrl(branding, 'favicon_url', 'appPublic.branding.favicon_url'), - theme_color: readNullableNonEmptyString(branding, 'theme_color', 'appPublic.branding.theme_color'), + theme_color: normalizeThemeColor(branding.theme_color), status_page_url: readOptionalText(branding, 'status_page_url'), status_page_incident_history_url: readOptionalText(branding, 'status_page_incident_history_url'), premium_product_name: readOptionalText(branding, 'premium_product_name') ?? DEFAULT_PREMIUM_PRODUCT_NAME, diff --git a/fluxer_app/src/features/theme/utils/InstanceBrandColors.ts b/fluxer_app/src/features/theme/utils/InstanceBrandColors.ts index de514dcca..a5b6f60f7 100644 --- a/fluxer_app/src/features/theme/utils/InstanceBrandColors.ts +++ b/fluxer_app/src/features/theme/utils/InstanceBrandColors.ts @@ -10,19 +10,11 @@ export const INSTANCE_BRAND_VARIABLES = [ type InstanceBrandVariable = (typeof INSTANCE_BRAND_VARIABLES)[number]; -const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; +const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/; function parseHexColor(value: string): [number, number, number] | null { - const trimmed = value.trim(); - if (!HEX_COLOR_PATTERN.test(trimmed)) return null; - const digits = - trimmed.length === 4 - ? trimmed - .slice(1) - .split('') - .map((digit) => digit + digit) - .join('') - : trimmed.slice(1); + if (!HEX_COLOR_PATTERN.test(value)) return null; + const digits = value.slice(1); return [ Number.parseInt(digits.slice(0, 2), 16), Number.parseInt(digits.slice(2, 4), 16), diff --git a/packages/instance_bootstrap/src/ThemeColor.ts b/packages/instance_bootstrap/src/ThemeColor.ts new file mode 100644 index 000000000..938d64ceb --- /dev/null +++ b/packages/instance_bootstrap/src/ThemeColor.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const HEX_THEME_COLOR_PATTERN = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i; +const UNSET_THEME_COLOR = '#000000'; + +export function normalizeThemeColor(value: unknown): string | null { + if (typeof value !== 'string') return null; + const match = HEX_THEME_COLOR_PATTERN.exec(value.trim()); + if (match === null) return null; + const digits = match[1].toLowerCase(); + const color = `#${digits.length === 3 ? digits.replace(/./g, '$&$&') : digits}`; + return color === UNSET_THEME_COLOR ? null : color; +}