fix(api): store branding images as media references (#3313)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 20:20:34 +02:00
1 parent 847d449882
commit 4ecbe9603c
4 files changed
+211 -5

No files matched your search

@@ -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<T extends Partial<Record<BrandingAssetField, string | null | undefined>>>(
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;
}
@@ -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);
@@ -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<T extends Partial<Record<BrandingAssetField, string | null>>>(branding: T): T {
return mapBrandingAssets(branding, (value) => toStoredBrandingAsset(value, Config.endpoints.media));
}
function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSchema>): 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<number> {
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<string, string>();
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<InstancePolicyConfig> {
const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY);
return parseStoredInstancePolicyConfig(raw);
+8
View File
@@ -294,6 +294,14 @@ export async function startWorkerMain(): Promise<void> {
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()),