mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(api): store branding images as media references (#3313)
This commit is contained in:
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);
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in new issue
Block a user