Files
backspace/packages/web/src/utils/assetUrls.ts
T
Jannis Braun cbef0fe0f8 fix: resolve federated avatar double-path URL that broke cross-instance profile pictures
profileSync stored avatar/banner paths with /api/uploads/ prefix on remote
instances, causing resolveAssetUrl to produce double-path URLs like
https://remote/api/uploads//api/uploads/file.jpg that 404'd. Store bare
filenames instead, strip prefix defensively in resolveAssetUrl and server-side
for existing data self-healing.
2026-03-17 00:36:45 +01:00

79 lines
3.3 KiB
TypeScript

import { getApiForOrigin } from '../stores/spaceStore';
/** Strip /api/uploads/ prefix to get bare filename. No-op for bare filenames and absolute URLs. */
export function stripUploadPrefix(filename: string): string {
return filename.startsWith('/api/uploads/') ? filename.slice('/api/uploads/'.length) : filename;
}
/**
* Resolve a relative asset filename to an absolute URL for remote origins.
* Home-origin filenames are returned as-is (components handle the /api/uploads/ prefix).
* Already-absolute URLs (starting with 'http') pass through unchanged.
*/
export function resolveAssetUrl(filename: string | null | undefined, origin: string): typeof filename {
if (!filename || !origin || filename.startsWith('http')) return filename;
return getApiForOrigin(origin).uploads.url(stripUploadPrefix(filename));
}
/**
* Rewrite the avatar field on a user-like object for remote origins.
* Mutates in-place for efficiency (called on arrays of members/messages).
*/
export function normalizeUserAssets<T extends { avatar?: string | null; banner?: string | null }>(user: T, origin: string): T {
if (!origin) return user;
if (user.avatar) {
user.avatar = resolveAssetUrl(user.avatar, origin) ?? user.avatar;
}
if (user.banner) {
user.banner = resolveAssetUrl(user.banner, origin) ?? user.banner;
}
// Qualify users local to the remote instance with their origin domain.
// These users have no homeInstance (they're native there), so the client
// can't distinguish them from its own local users without this step.
const u = user as Record<string, unknown>;
if (typeof u.username === 'string' && typeof u.id === 'string' && !u.homeInstance) {
try {
const host = new URL(origin).host;
u.homeInstance = host;
if (!u.homeUserId) u.homeUserId = u.id;
if (!(u.username as string).includes('@')) {
u.username = `${u.username}@${host}`;
}
} catch {}
}
return user;
}
/**
* Rewrite user.avatar and attachment filenames on a message for remote origins.
* Also normalizes nested replyTo message assets. Mutates in-place.
*/
export function normalizeMessageAssets<T extends { user: { avatar?: string | null }; attachments?: { filename: string; thumbnailFilename?: string | null }[]; replyTo?: { user: { avatar?: string | null }; attachments?: { filename: string; thumbnailFilename?: string | null }[] } | null }>(
message: T,
origin: string,
): T {
if (!origin) return message;
normalizeUserAssets(message.user, origin);
if (message.attachments) {
for (const att of message.attachments) {
att.filename = resolveAssetUrl(att.filename, origin) ?? att.filename;
if (att.thumbnailFilename) {
att.thumbnailFilename = resolveAssetUrl(att.thumbnailFilename, origin) ?? att.thumbnailFilename;
}
}
}
// Normalize reply-to message assets (remote replies have relative URLs)
if (message.replyTo) {
normalizeUserAssets(message.replyTo.user, origin);
if (message.replyTo.attachments) {
for (const att of message.replyTo.attachments) {
att.filename = resolveAssetUrl(att.filename, origin) ?? att.filename;
if (att.thumbnailFilename) {
att.thumbnailFilename = resolveAssetUrl(att.thumbnailFilename, origin) ?? att.thumbnailFilename;
}
}
}
}
return message;
}