Files
twenty/packages/twenty-server/src/utils/image.ts
T
Abdul Rahman f4ff234db8 feat: make record avatar/icon resolution data-driven via a configurable image identifier field (#22644)
## Summary

Today the avatar/icon shown for a record is hardcoded per object —
Company pulls a favicon from its domain link, Person uses `avatarUrl`,
etc. This PR replaces that hardcoding with a generic, data-driven
abstraction based on a configurable **image identifier field** on each
object's metadata (mirroring the existing **label identifier** concept).

An object's image identifier can point to:
- a **`FILES`** field → the uploaded image is used directly (rounded
avatar), or
- a **`LINKS`** field → a favicon is derived from the primary URL via
the Twenty icons service (squared avatar), gated by
`ALLOW_REQUESTS_TO_TWENTY_ICONS`.

This lets any object type (Opportunity, a custom "Listing", etc.) define
its own avatar/icon without code changes, and makes the field
configurable/overridable for standard objects.


##  Open question: also allow `TEXT` → direct image URL?
Right now the image identifier is restricted to `FILES` (uploaded file)
and `LINKS` (favicon). We deliberately left out `TEXT` → **direct image
URL** (e.g. an imported/synced photo URL stored in a text field).
There's precedent for it — Person's avatar was originally a `TEXT`
`avatarUrl`, and WorkspaceMember still is — and it's unambiguous (a
`TEXT` field has no favicon-vs-image ambiguity, and selecting it as the
image identifier is itself the declaration of intent). It's a small,
clean extension:
- add `TEXT` to the allowed image-identifier types,
- add an explicit `TEXT → raw URL` case
- `getAvatarType`: `TEXT → rounded`.
Caveats: it relies on admin assertion that the text values are image
URLs (no data-level guarantee), and external image URLs load third-party
content in the browser (IP-leak/hotlinking, same as favicons — a
proxy/cache would be the more robust long-term answer).

###  Resolution
Decision: **we will not support `TEXT` as an image identifier.** Image
identifiers stay restricted to `FILES` and `LINKS`, and any other type
fails closed (returns no avatar) on both the frontend and backend.
Instead, the legacy items that still rely on a `TEXT` avatar — Person's
deprecated `avatarUrl` and WorkspaceMember's `avatarUrl` — will be
migrated to `FILE` fields in a follow-up PR. Until then, WorkspaceMember
remains an exception (its `avatarUrl` still resolves through the
existing CorePicture path), and legacy Person `avatarUrl` values that
haven't been migrated will show initials placeholders.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22644?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-15 19:15:47 +05:30

66 lines
1.9 KiB
TypeScript

import { detectPdf } from '@file-type/pdf';
import { type AxiosInstance } from 'axios';
import { isNonEmptyString } from '@sniptt/guards';
import { FileTypeParser } from 'file-type';
import { isDefined } from 'twenty-shared/utils';
export const getImageBufferFromUrl = async (
url: string,
axiosInstance: AxiosInstance,
): Promise<Buffer> => {
if (!isNonEmptyString(url) || url.trim().length === 0) {
throw new Error('Invalid URL provided: URL must be a non-empty string');
}
try {
const response = await axiosInstance.get(url, {
responseType: 'arraybuffer',
validateStatus: (status) => status >= 200 && status < 300,
maxRedirects: 5,
timeout: 10000,
});
if (!response.data) {
throw new Error('Received empty response from image URL');
}
const bufferLength = Buffer.isBuffer(response.data)
? response.data.length
: response.data.byteLength;
if (bufferLength === 0) {
throw new Error('Received empty response from image URL');
}
const contentType = response.headers['content-type'];
if (isNonEmptyString(contentType) && !contentType.startsWith('image/')) {
throw new Error(
`Invalid content type: expected image/*, got ${contentType}`,
);
}
return Buffer.from(response.data, 'binary');
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to fetch image from ${url}: ${message}`);
}
};
export const fetchImageWithTypeFromUrl = async (
imageUrl: string,
axiosInstance: AxiosInstance,
): Promise<{ buffer: Buffer; extension: string } | undefined> => {
const buffer = await getImageBufferFromUrl(imageUrl, axiosInstance);
const parser = new FileTypeParser({ customDetectors: [detectPdf] });
const type = await parser.fromBuffer(buffer);
if (!isDefined(type) || !type.mime.startsWith('image/')) {
return undefined;
}
return { buffer, extension: type.ext };
};