From 4ecbe9603caf0ebbc611833da83af66ee85da39e Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 8 Oct 2026 20:20:34 +0200 Subject: [PATCH] fix(api): store branding images as media references (#3313) --- .../api/instance/BrandingAssetReferences.ts | 67 ++++++++++++++ .../instance/InstanceConfigRepository.test.ts | 50 ++++++++++ .../api/instance/InstanceConfigRepository.ts | 91 ++++++++++++++++++- fluxer_api/src/api/worker/WorkerMain.ts | 8 ++ 4 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 fluxer_api/src/api/instance/BrandingAssetReferences.ts diff --git a/fluxer_api/src/api/instance/BrandingAssetReferences.ts b/fluxer_api/src/api/instance/BrandingAssetReferences.ts new file mode 100644 index 000000000..34e050ff3 --- /dev/null +++ b/fluxer_api/src/api/instance/BrandingAssetReferences.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const BRANDING_ASSET_FIELDS = ['icon_url', 'symbol_url', 'logo_url', 'wordmark_url', 'favicon_url'] as const; + +export type BrandingAssetField = (typeof BRANDING_ASSET_FIELDS)[number]; + +const BRANDING_ASSET_REFERENCE = /^branding\/\d{1,20}\/(?:a_)?[0-9a-f]{8}\.[a-z0-9]{2,5}$/; +const BRANDING_ASSET_PATH = /\/(branding\/\d{1,20}\/(?:a_)?[0-9a-f]{8}\.[a-z0-9]{2,5})$/; + +function trimTrailingSlashes(value: string): string { + return value.replace(/\/+$/u, ''); +} + +export function isBrandingAssetReference(value: string): boolean { + return BRANDING_ASSET_REFERENCE.test(value); +} + +export function brandingAssetReferenceFromOwnUrl(value: string, mediaEndpoint: string): string | null { + const prefix = `${trimTrailingSlashes(mediaEndpoint)}/`; + if (!value.startsWith(prefix)) return null; + const remainder = value.slice(prefix.length); + return isBrandingAssetReference(remainder) ? remainder : null; +} + +export function brandingAssetReferenceFromAnyUrl(value: string): string | null { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.search !== '' || url.hash !== '') return null; + return BRANDING_ASSET_PATH.exec(url.pathname)?.[1] ?? null; +} + +export function brandingAssetStorageKey(reference: string): string { + const slash = reference.lastIndexOf('/'); + const file = reference.slice(slash + 1); + const hash = file.slice(0, file.indexOf('.')).replace(/^a_/u, ''); + return `${reference.slice(0, slash)}/${hash}`; +} + +export function toStoredBrandingAsset( + value: string | null | undefined, + mediaEndpoint: string, +): string | null | undefined { + if (value === undefined || value === null) return value; + return brandingAssetReferenceFromOwnUrl(value, mediaEndpoint) ?? value; +} + +export function resolveBrandingAsset(value: string | null, mediaEndpoint: string): string | null { + if (value === null || !isBrandingAssetReference(value)) return value; + return `${trimTrailingSlashes(mediaEndpoint)}/${value}`; +} + +export function mapBrandingAssets>>( + branding: T, + map: (value: string | null | undefined) => string | null | undefined, +): T { + const next = {...branding}; + for (const field of BRANDING_ASSET_FIELDS) { + if (field in next) { + next[field] = map(next[field]) as T[BrandingAssetField]; + } + } + return next; +} diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts index c06acd394..87f897309 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts @@ -3,6 +3,7 @@ import {spawnSync} from 'node:child_process'; import {createHash} from 'node:crypto'; import {createServer} from 'node:net'; +import {Config} from '@app/api/Config'; import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution'; import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution'; import type {PreparedQuery} from '@app/api/database/CassandraTypes'; @@ -222,6 +223,55 @@ describe('InstanceConfigRepository', () => { expect(config.branding.product_name).toBe('Kept'); }); + it('stores uploaded branding assets as references and resolves them against the current media endpoint', async () => { + setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor()); + const repository = createRepository(new MockKVProvider()); + const media = Config.endpoints.media; + const foreign = 'https://cdn.example.com/favicon.ico'; + + await repository.setAppPublicConfig({ + branding: {favicon_url: `${media}/branding/0/0123abcd.png`, logo_url: foreign}, + }); + + const stored = JSON.parse((await repository.getConfig(APP_PUBLIC_CONFIG_KEY)) ?? '{}'); + expect(stored.branding.favicon_url).toBe('branding/0/0123abcd.png'); + expect(stored.branding.logo_url).toBe(foreign); + Config.endpoints.media = 'https://media.moved.example'; + try { + const config = await repository.getAppPublicConfig(); + expect(config.branding.favicon_url).toBe('https://media.moved.example/branding/0/0123abcd.png'); + expect(config.branding.logo_url).toBe(foreign); + } finally { + Config.endpoints.media = media; + } + }); + + it('normalises legacy branding URLs from an old domain only when the object is ours', async () => { + setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor()); + const repository = createRepository(new MockKVProvider()); + await repository.setConfig( + APP_PUBLIC_CONFIG_KEY, + JSON.stringify({ + branding: { + favicon_url: 'https://old.example/media/branding/0/a_0123abcd.gif', + icon_url: 'https://other.example/branding/0/89abcdef.png', + }, + }), + ); + const storage = { + getObjectMetadata: vi.fn(async (_bucket: string, key: string) => + key === 'branding/0/0123abcd' ? {contentLength: 1, contentType: 'image/gif'} : null, + ), + }; + + expect(await repository.normalizeStoredBrandingAssets(storage as never)).toBe(1); + expect(await repository.normalizeStoredBrandingAssets(storage as never)).toBe(0); + + const config = await repository.getAppPublicConfig(); + expect(config.branding.favicon_url).toBe(`${Config.endpoints.media}/branding/0/a_0123abcd.gif`); + expect(config.branding.icon_url).toBe('https://other.example/branding/0/89abcdef.png'); + }); + it('keeps valid stored instance policy flags when one field is invalid', async () => { const executor = new CountingInMemoryCassandraQueryExecutor(); setCassandraQueryExecutorForTesting(executor); diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.ts index fe9fbae97..43bfb7621 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -7,12 +7,23 @@ import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/datab import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes'; import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes'; import {syncChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate'; +import type {IStorageService} from '@app/api/infrastructure/IStorageService'; import { type AccountIdentity, resolveAccountIdentity, type StoredAccountIdentity, setCachedAccountIdentity, } from '@app/api/instance/AccountIdentityModeCache'; +import { + BRANDING_ASSET_FIELDS, + type BrandingAssetField, + brandingAssetReferenceFromAnyUrl, + brandingAssetStorageKey, + isBrandingAssetReference, + mapBrandingAssets, + resolveBrandingAsset, + toStoredBrandingAsset, +} from '@app/api/instance/BrandingAssetReferences'; import { getDefaultDateOfBirthCollection, setCachedDateOfBirthCollection, @@ -684,11 +695,26 @@ const StoredInstanceAppPublicSchema = InstanceAppPublicSchema.extend({ }); function parseStoredAppPublicConfig(raw: string | null): InstanceAppPublicConfig { - return buildAppPublicConfig( - salvageStoredConfig(StoredInstanceAppPublicSchema, readStoredConfigValue(raw, 'app public'), 'app public'), + return resolveAppPublicConfig( + buildAppPublicConfig( + salvageStoredConfig(StoredInstanceAppPublicSchema, readStoredConfigValue(raw, 'app public'), 'app public'), + ), ); } +function resolveAppPublicConfig(config: InstanceAppPublicConfig): InstanceAppPublicConfig { + return { + ...config, + branding: mapBrandingAssets(config.branding, (value) => + resolveBrandingAsset(value ?? null, Config.endpoints.media), + ), + }; +} + +function toStoredBranding>>(branding: T): T { + return mapBrandingAssets(branding, (value) => toStoredBrandingAsset(value, Config.endpoints.media)); +} + function buildAppPublicConfig(config: z.infer): InstanceAppPublicConfig { const defaults = getDefaultAppPublicConfig(); const {branding = {}, setup = {}, legal = {}, registration = {}} = config; @@ -1658,8 +1684,8 @@ export class InstanceConfigRepository { StoredInstanceAppPublicSchema, { branding: { - ...current.branding, - ...(config.branding ?? {}), + ...toStoredBranding(current.branding), + ...toStoredBranding(config.branding ?? {}), premium_product_name: premiumProductName, }, setup: { @@ -1677,13 +1703,68 @@ export class InstanceConfigRepository { }, 'app public', ); - return {value: JSON.stringify(merged), result: buildAppPublicConfig(merged)}; + return {value: JSON.stringify(merged), result: resolveAppPublicConfig(buildAppPublicConfig(merged))}; }); await this.publishRefresh(cache.sourceId); setCachedDateOfBirthCollection(next.registration.collect_date_of_birth); return next; } + async normalizeStoredBrandingAssets(storageService: IStorageService): Promise { + const cache = this.configCache; + await cache.getSnapshot(); + cache.assertActive(); + const raw = await this.fetchConfigFromDatabase(APP_PUBLIC_CONFIG_KEY); + if (raw === null) return 0; + const stored = salvageStoredConfig( + StoredInstanceAppPublicSchema, + readStoredConfigValue(raw, 'app public'), + 'app public', + ); + const rewrites = new Map(); + for (const field of BRANDING_ASSET_FIELDS) { + const value = stored.branding?.[field]; + if (typeof value !== 'string' || isBrandingAssetReference(value) || rewrites.has(value)) continue; + const reference = toStoredBrandingAsset(value, Config.endpoints.media); + if (reference !== value && typeof reference === 'string') { + rewrites.set(value, reference); + continue; + } + const candidate = brandingAssetReferenceFromAnyUrl(value); + if (candidate === null) continue; + const metadata = await storageService.getObjectMetadata( + Config.s3.buckets.cdn, + brandingAssetStorageKey(candidate), + ); + if (metadata !== null) { + rewrites.set(value, candidate); + } + } + if (rewrites.size === 0) return 0; + const {result} = await this.compareAndSetStoredValue(cache, APP_PUBLIC_CONFIG_KEY, (current) => { + const latest = salvageStoredConfig( + StoredInstanceAppPublicSchema, + readStoredConfigValue(current, 'app public'), + 'app public', + ); + if (!latest.branding) return {value: null, result: 0}; + let changed = 0; + const branding = mapBrandingAssets(latest.branding, (value) => { + const rewrite = typeof value === 'string' ? rewrites.get(value) : undefined; + if (rewrite === undefined) return value; + changed++; + return rewrite; + }); + if (changed === 0) return {value: null, result: 0}; + const next = validateStoredConfig(StoredInstanceAppPublicSchema, {...latest, branding}, 'app public'); + return {value: JSON.stringify(next), result: changed}; + }); + if (result > 0) { + await this.publishRefresh(cache.sourceId); + } + return result; + } + async getInstancePolicyConfig(): Promise { const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY); return parseStoredInstancePolicyConfig(raw); diff --git a/fluxer_api/src/api/worker/WorkerMain.ts b/fluxer_api/src/api/worker/WorkerMain.ts index 764ed1143..d89fd2591 100644 --- a/fluxer_api/src/api/worker/WorkerMain.ts +++ b/fluxer_api/src/api/worker/WorkerMain.ts @@ -294,6 +294,14 @@ export async function startWorkerMain(): Promise { await startContentBlocklistCaches({kvClient: dependencies.kvClient, storageService: dependencies.storageService}); Logger.info('Content blocklist caches initialised for worker backend'); await queueBlocklistFeedStartupJobs(dependencies.kvClient, workerService, Config.blocklistFeeds.enabled); + try { + const normalized = await getInstanceConfigRepository().normalizeStoredBrandingAssets(dependencies.storageService); + if (normalized > 0) { + Logger.info({normalized}, 'Normalised stored instance branding assets to references'); + } + } catch (error) { + Logger.warn({err: error}, 'Failed to normalise stored instance branding assets'); + } setActivityProcessChannel('worker'); await startActivityEvents({ publisher: jetStreamActivityPublisher(jsConnectionManager.getJetStreamClient()),