fix: favicons

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-05 21:23:11 +00:00
parent 05ccbc372c
commit 02897e11cb
7 changed files with 1281 additions and 556 deletions

View File

@@ -47,7 +47,7 @@
"sqlstring": "^2.3.3",
"superjson": "^1.13.3",
"svix": "^1.24.0",
"url-metadata": "^4.1.1",
"url-metadata": "^5.4.1",
"uuid": "^9.0.1",
"zod": "catalog:"
},

View File

@@ -71,7 +71,7 @@ async function fetchImage(
url: URL,
): Promise<{ buffer: Buffer; contentType: string; status: number }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
const timeout = setTimeout(() => controller.abort(), 1000); // 10s timeout
try {
const response = await fetch(url.toString(), {
@@ -175,20 +175,10 @@ async function processImage(
bufferSize: buffer.length,
});
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
throw error;
}
}
// Create a simple transparent fallback image when Sharp can't process the original
function createFallbackImage(): Buffer {
// 1x1 transparent PNG
return Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
}
// Process OG image with Sharp (resize to 300px width)
async function processOgImage(
buffer: Buffer,
@@ -220,8 +210,7 @@ async function processOgImage(
bufferSize: buffer.length,
});
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
throw error;
}
}
@@ -241,11 +230,15 @@ export async function getFavicon(
reply: FastifyReply,
) {
try {
logger.info('getFavicon', {
url: request.query.url,
});
const url = validateUrl(request.query.url);
if (!url) {
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=3600');
return reply.send(createFallbackImage());
return reply
.status(404)
.header('Content-Type', 'text/plain')
.send('Not found');
}
const cacheKey = createCacheKey(url.toString());
@@ -259,11 +252,13 @@ export async function getFavicon(
}
let imageUrl: URL;
// If it's a direct image URL, use it directly
if (isDirectImage(url)) {
imageUrl = url;
} else {
logger.info('before parseUrlMeta', {
url: url.toString(),
});
// For website URLs, extract favicon from HTML
const meta = await parseUrlMeta(url.toString());
logger.info('parseUrlMeta result', {
@@ -323,9 +318,10 @@ export async function getFavicon(
// Accept any response as long as we have valid image data
if (buffer.length === 0) {
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=3600');
return reply.send(createFallbackImage());
return reply
.status(404)
.header('Content-Type', 'text/plain')
.send('Not found');
}
// Process the image (resize to 30x30 PNG, or serve ICO as-is)

View File

@@ -1,3 +1,5 @@
process.env.TZ = 'UTC';
import compress from '@fastify/compress';
import cookie from '@fastify/cookie';
import cors, { type FastifyCorsOptions } from '@fastify/cors';
@@ -12,7 +14,7 @@ import {
type IServiceClientWithProject,
runWithAlsSession,
} from '@openpanel/db';
import { getCache, getRedisPub } from '@openpanel/redis';
import { getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc';
@@ -50,8 +52,6 @@ import { logger } from './utils/logger';
sourceMapSupport.install();
process.env.TZ = 'UTC';
declare module 'fastify' {
interface FastifyRequest {
client: IServiceClientWithProject | null;

View File

@@ -72,7 +72,9 @@ interface UrlMetaData {
export async function parseUrlMeta(url: string) {
try {
const metadata = (await urlMetadata(url)) as UrlMetaData;
const metadata = (await urlMetadata(url, {
timeout: 500,
})) as UrlMetaData;
const data = transform(metadata, url);
return data;
} catch (err) {

View File

@@ -1,188 +0,0 @@
import type { LucideIcon, LucideProps } from 'lucide-react';
const createFlagIcon = (url: string) => {
return ((_props: LucideProps) => (
<span
className={`fi !block aspect-[1.33] overflow-hidden rounded-[2px] fi-${url}`}
/>
)) as LucideIcon;
};
const data = {
ie: createFlagIcon('ie'),
tw: createFlagIcon('tw'),
py: createFlagIcon('py'),
kr: createFlagIcon('kr'),
nz: createFlagIcon('nz'),
do: createFlagIcon('do'),
cl: createFlagIcon('cl'),
dz: createFlagIcon('dz'),
np: createFlagIcon('np'),
ma: createFlagIcon('ma'),
gh: createFlagIcon('gh'),
zm: createFlagIcon('zm'),
pa: createFlagIcon('pa'),
tn: createFlagIcon('tn'),
lk: createFlagIcon('lk'),
sv: createFlagIcon('sv'),
ve: createFlagIcon('ve'),
sn: createFlagIcon('sn'),
gt: createFlagIcon('gt'),
xk: createFlagIcon('xk'),
jm: createFlagIcon('jm'),
cm: createFlagIcon('cm'),
ni: createFlagIcon('ni'),
uy: createFlagIcon('uy'),
ss: createFlagIcon('ss'),
cd: createFlagIcon('cd'),
cu: createFlagIcon('cu'),
kh: createFlagIcon('kh'),
bb: createFlagIcon('bb'),
gf: createFlagIcon('gf'),
et: createFlagIcon('et'),
pe: createFlagIcon('pe'),
mo: createFlagIcon('mo'),
mn: createFlagIcon('mn'),
hn: createFlagIcon('hn'),
cn: createFlagIcon('cn'),
ng: createFlagIcon('ng'),
se: createFlagIcon('se'),
jp: createFlagIcon('jp'),
hk: createFlagIcon('hk'),
us: createFlagIcon('us'),
gb: createFlagIcon('gb'),
ua: createFlagIcon('ua'),
ru: createFlagIcon('ru'),
de: createFlagIcon('de'),
fr: createFlagIcon('fr'),
br: createFlagIcon('br'),
in: createFlagIcon('in'),
it: createFlagIcon('it'),
es: createFlagIcon('es'),
pl: createFlagIcon('pl'),
nl: createFlagIcon('nl'),
id: createFlagIcon('id'),
tr: createFlagIcon('tr'),
ph: createFlagIcon('ph'),
ca: createFlagIcon('ca'),
ar: createFlagIcon('ar'),
mx: createFlagIcon('mx'),
za: createFlagIcon('za'),
au: createFlagIcon('au'),
co: createFlagIcon('co'),
ch: createFlagIcon('ch'),
at: createFlagIcon('at'),
be: createFlagIcon('be'),
pt: createFlagIcon('pt'),
my: createFlagIcon('my'),
th: createFlagIcon('th'),
vn: createFlagIcon('vn'),
sg: createFlagIcon('sg'),
eg: createFlagIcon('eg'),
sa: createFlagIcon('sa'),
pk: createFlagIcon('pk'),
bd: createFlagIcon('bd'),
ro: createFlagIcon('ro'),
hu: createFlagIcon('hu'),
cz: createFlagIcon('cz'),
gr: createFlagIcon('gr'),
il: createFlagIcon('il'),
no: createFlagIcon('no'),
fi: createFlagIcon('fi'),
dk: createFlagIcon('dk'),
sk: createFlagIcon('sk'),
bg: createFlagIcon('bg'),
hr: createFlagIcon('hr'),
rs: createFlagIcon('rs'),
ba: createFlagIcon('ba'),
si: createFlagIcon('si'),
lv: createFlagIcon('lv'),
lt: createFlagIcon('lt'),
ee: createFlagIcon('ee'),
by: createFlagIcon('by'),
md: createFlagIcon('md'),
kz: createFlagIcon('kz'),
uz: createFlagIcon('uz'),
kg: createFlagIcon('kg'),
tj: createFlagIcon('tj'),
tm: createFlagIcon('tm'),
az: createFlagIcon('az'),
ge: createFlagIcon('ge'),
am: createFlagIcon('am'),
af: createFlagIcon('af'),
ir: createFlagIcon('ir'),
iq: createFlagIcon('iq'),
sy: createFlagIcon('sy'),
lb: createFlagIcon('lb'),
jo: createFlagIcon('jo'),
ps: createFlagIcon('ps'),
kw: createFlagIcon('kw'),
qa: createFlagIcon('qa'),
om: createFlagIcon('om'),
ye: createFlagIcon('ye'),
ae: createFlagIcon('ae'),
bh: createFlagIcon('bh'),
cy: createFlagIcon('cy'),
mt: createFlagIcon('mt'),
sm: createFlagIcon('sm'),
li: createFlagIcon('li'),
is: createFlagIcon('is'),
al: createFlagIcon('al'),
mk: createFlagIcon('mk'),
me: createFlagIcon('me'),
ad: createFlagIcon('ad'),
lu: createFlagIcon('lu'),
mc: createFlagIcon('mc'),
fo: createFlagIcon('fo'),
gg: createFlagIcon('gg'),
je: createFlagIcon('je'),
im: createFlagIcon('im'),
gi: createFlagIcon('gi'),
va: createFlagIcon('va'),
ax: createFlagIcon('ax'),
bl: createFlagIcon('bl'),
mf: createFlagIcon('mf'),
pm: createFlagIcon('pm'),
yt: createFlagIcon('yt'),
wf: createFlagIcon('wf'),
tf: createFlagIcon('tf'),
re: createFlagIcon('re'),
sc: createFlagIcon('sc'),
mu: createFlagIcon('mu'),
zw: createFlagIcon('zw'),
mz: createFlagIcon('mz'),
na: createFlagIcon('na'),
bw: createFlagIcon('bw'),
ls: createFlagIcon('ls'),
sz: createFlagIcon('sz'),
bi: createFlagIcon('bi'),
rw: createFlagIcon('rw'),
ug: createFlagIcon('ug'),
ke: createFlagIcon('ke'),
tz: createFlagIcon('tz'),
mg: createFlagIcon('mg'),
cr: createFlagIcon('cr'),
ky: createFlagIcon('ky'),
gy: createFlagIcon('gy'),
mm: createFlagIcon('mm'),
la: createFlagIcon('la'),
gl: createFlagIcon('gl'),
gp: createFlagIcon('gp'),
fj: createFlagIcon('fj'),
cv: createFlagIcon('cv'),
gn: createFlagIcon('gn'),
bj: createFlagIcon('bj'),
bo: createFlagIcon('bo'),
bq: createFlagIcon('bq'),
bs: createFlagIcon('bs'),
ly: createFlagIcon('ly'),
bn: createFlagIcon('bn'),
tt: createFlagIcon('tt'),
sr: createFlagIcon('sr'),
ec: createFlagIcon('ec'),
mv: createFlagIcon('mv'),
pr: createFlagIcon('pr'),
ci: createFlagIcon('ci'),
};
export default data;

View File

@@ -1,6 +1,8 @@
import type { LucideIcon, LucideProps } from 'lucide-react';
import type { LucideProps } from 'lucide-react';
import {
ActivityIcon,
BookIcon,
CpuIcon,
ExternalLinkIcon,
HelpCircleIcon,
MailIcon,
@@ -14,96 +16,343 @@ import {
TabletIcon,
TvIcon,
} from 'lucide-react';
import { useMemo } from 'react';
import { useState } from 'react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { useAppContext } from '@/hooks/use-app-context';
import flags from './serie-icon.flags';
import iconsWithUrls from './serie-icon.urls';
// ============================================================================
// Types
// ============================================================================
type SerieIconProps = Omit<LucideProps, 'name'> & {
name?: string | string[];
};
function getProxyImage(url: string) {
return `/misc/favicon?url=${encodeURIComponent(url)}`;
}
type IconType = 'lucide' | 'image' | 'flag';
const createImageIcon = (url: string) => {
return ((_props: LucideProps) => {
const context = useAppContext();
return (
<img
alt="serie icon"
className="w-full max-h-4 rounded-[2px] object-contain"
src={context.apiUrl?.replace(/\/$/, '') + url}
loading="lazy"
decoding="async"
/>
);
}) as LucideIcon;
};
type ResolvedIcon =
| { type: 'lucide'; Icon: React.ComponentType<LucideProps> }
| { type: 'image'; url: string }
| { type: 'flag'; code: string }
| null;
const mapper: Record<string, LucideIcon> = {
// ============================================================================
// Constants
// ============================================================================
const LUCIDE_ICONS: Record<string, React.ComponentType<LucideProps>> = {
// Events
screen_view: MonitorPlayIcon,
session_start: ActivityIcon,
session_end: ActivityIcon,
link_out: ExternalLinkIcon,
// Misc
// Devices
smarttv: TvIcon,
mobile: SmartphoneIcon,
desktop: MonitorIcon,
tablet: TabletIcon,
// Sources
search: SearchIcon,
social: PodcastIcon,
email: MailIcon,
podcast: PodcastIcon,
comment: MessageCircleIcon,
tech: CpuIcon,
content: BookIcon,
// Misc
unknown: HelpCircleIcon,
[NOT_SET_VALUE]: ScanIcon,
...Object.entries(iconsWithUrls).reduce(
(acc, [key, value]) => ({
...acc,
[key]: createImageIcon(getProxyImage(value)),
}),
{},
),
...flags,
};
const FLAG_CODES = new Set([
'ie',
'tw',
'py',
'kr',
'nz',
'do',
'cl',
'dz',
'np',
'ma',
'gh',
'zm',
'pa',
'tn',
'lk',
'sv',
've',
'sn',
'gt',
'xk',
'jm',
'cm',
'ni',
'uy',
'ss',
'cd',
'cu',
'kh',
'bb',
'gf',
'et',
'pe',
'mo',
'mn',
'hn',
'cn',
'ng',
'se',
'jp',
'hk',
'us',
'gb',
'ua',
'ru',
'de',
'fr',
'br',
'in',
'it',
'es',
'pl',
'nl',
'id',
'tr',
'ph',
'ca',
'ar',
'mx',
'za',
'au',
'co',
'ch',
'at',
'be',
'pt',
'my',
'th',
'vn',
'sg',
'eg',
'sa',
'pk',
'bd',
'ro',
'hu',
'cz',
'gr',
'il',
'no',
'fi',
'dk',
'sk',
'bg',
'hr',
'rs',
'ba',
'si',
'lv',
'lt',
'ee',
'by',
'md',
'kz',
'uz',
'kg',
'tj',
'tm',
'az',
'ge',
'am',
'af',
'ir',
'iq',
'sy',
'lb',
'jo',
'ps',
'kw',
'qa',
'om',
'ye',
'ae',
'bh',
'cy',
'mt',
'sm',
'li',
'is',
'al',
'mk',
'me',
'ad',
'lu',
'mc',
'fo',
'gg',
'je',
'im',
'gi',
'va',
'ax',
'bl',
'mf',
'pm',
'yt',
'wf',
'tf',
're',
'sc',
'mu',
'zw',
'mz',
'na',
'bw',
'ls',
'sz',
'bi',
'rw',
'ug',
'ke',
'tz',
'mg',
'cr',
'ky',
'gy',
'mm',
'la',
'gl',
'gp',
'fj',
'cv',
'gn',
'bj',
'bo',
'bq',
'bs',
'ly',
'bn',
'tt',
'sr',
'ec',
'mv',
'pr',
'ci',
]);
function getProxyImageUrl(url: string): string {
return `/misc/favicon?url=${encodeURIComponent(url)}`;
}
function resolveIcon(name: string): ResolvedIcon {
const key = name.toLowerCase();
const lucideIcon = LUCIDE_ICONS[key];
if (lucideIcon) {
return { type: 'lucide', Icon: lucideIcon };
}
const imageUrl = iconsWithUrls[key as keyof typeof iconsWithUrls];
if (imageUrl) {
return { type: 'image', url: getProxyImageUrl(imageUrl) };
}
if (FLAG_CODES.has(key)) {
return { type: 'flag', code: key };
}
if (name.includes('http')) {
return { type: 'image', url: getProxyImageUrl(name) };
}
if (name.match(/^.+\.\w{2,3}$/)) {
return { type: 'image', url: getProxyImageUrl(`https://${name}`) };
}
return null;
}
function IconWrapper({ children }: { children: React.ReactNode }) {
return (
<div className={'relative max-h-4 flex-shrink-0 [&_svg]:!rounded-[2px]'}>
{children}
</div>
);
}
function ImageIcon({ url }: { url: string }) {
const context = useAppContext();
const [hasError, setHasError] = useState(false);
if (hasError) {
return null;
}
const fullUrl = context.apiUrl?.replace(/\/$/, '') + url;
return (
<IconWrapper>
<img
src={fullUrl}
alt=""
className="w-full max-h-4 rounded-[2px] object-contain"
loading="lazy"
decoding="async"
onError={() => setHasError(true)}
/>
</IconWrapper>
);
}
function FlagIcon({ code }: { code: string }) {
return (
<IconWrapper>
<span
className={`fi !block aspect-[1.33] overflow-hidden rounded-[2px] fi-${code}`}
/>
</IconWrapper>
);
}
function LucideIconWrapper({
Icon,
...props
}: { Icon: React.ComponentType<LucideProps> } & LucideProps) {
return (
<IconWrapper>
<Icon size={16} {...props} />
</IconWrapper>
);
}
// Main component
export function SerieIcon({ name: names, ...props }: SerieIconProps) {
const name = Array.isArray(names) ? names[0] : names;
const Icon = useMemo(() => {
if (!name) {
return null;
}
const mapped = mapper[name.toLowerCase()] ?? null;
if (mapped) {
return mapped;
}
if (name.includes('http')) {
return createImageIcon(getProxyImage(name));
}
// Matching image file name
if (name.match(/(.+)\.\w{2,3}$/)) {
return createImageIcon(getProxyImage(`https://${name}`));
}
if (!name) {
return null;
}, [name]);
}
return Icon ? (
<div className="relative max-h-4 flex-shrink-0 [&_svg]:!rounded-[2px]">
<Icon size={16} {...props} name={name} />
</div>
) : null;
const resolved = resolveIcon(name);
if (!resolved) {
return null;
}
switch (resolved.type) {
case 'lucide':
return <LucideIconWrapper Icon={resolved.Icon} {...props} />;
case 'image':
return <ImageIcon url={resolved.url} />;
case 'flag':
return <FlagIcon code={resolved.code} />;
}
}

1236
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff