Skip to content

Commit

Permalink
Refactor user images loading (#74)
Browse files Browse the repository at this point in the history
* fix(images): encode escape sequences in external images paths

* fix(images): apply fallback image without waiting for an error

* refactor(images): defined default user image url constant

* refactor(api): images loading
  • Loading branch information
seth2810 authored Aug 15, 2024
1 parent 98eeaf1 commit 99d38dc
Show file tree
Hide file tree
Showing 13 changed files with 91 additions and 106 deletions.
9 changes: 3 additions & 6 deletions src/components/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import clsx from 'clsx';
import { Image } from '@/components/Image';
import type { PropsWithChildren } from 'react';
import { defaultUserImageURL } from '@/contants';
import { isFacebookURL } from '@/utils/urls';

export type AvatarSize =
| 'xs'
Expand Down Expand Up @@ -71,12 +73,7 @@ export const Avatar = ({
width={sizes[size]}
height={sizes[size]}
alt={initials}
// If src contains fbcdn or fbsbx, use "URL"
src={
src.includes('fbcdn') || src.includes('fbsbx')
? 'https://cdn.stats.fm/file/statsfm/images/placeholders/users/private.webp'
: src
}
src={isFacebookURL(src) ? defaultUserImageURL : src}
{...props}
/>

Expand Down
50 changes: 20 additions & 30 deletions src/components/Image/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,28 @@
import type { ImageProps as NextImageProps } from 'next/image';
import NextImage from 'next/image';
import clsx from 'clsx';
import { useState } from 'react';

interface Props extends NextImageProps {
rounded?: boolean;
}

export const Image = ({ rounded = false, className, src, ...props }: Props) => {
const [error, setError] = useState(false);
return (
<div className={clsx('overflow-hidden', rounded && 'rounded-full')}>
<NextImage
className={clsx(
'bg-foreground before:grid before:h-full before:place-items-center before:p-2 before:text-center',
rounded && 'rounded-full',
className,
)}
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAQSURBVHgBAQUA+v8ANTU1/wLhAZ/HkyyzAAAAAElFTkSuQmCC"
style={{
maxWidth: '100%',
height: 'auto',
objectFit: 'cover',
}}
loading="lazy"
src={
error
? 'https://cdn.stats.fm/file/statsfm/images/placeholders/users/private.webp'
: src
}
onError={() => setError(true)}
{...props}
/>
</div>
);
};
export const Image = ({ rounded = false, className, ...props }: Props) => (
<div className={clsx('overflow-hidden', rounded && 'rounded-full')}>
<NextImage
className={clsx(
'bg-foreground before:grid before:h-full before:place-items-center before:p-2 before:text-center',
rounded && 'rounded-full',
className,
)}
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAQSURBVHgBAQUA+v8ANTU1/wLhAZ/HkyyzAAAAAElFTkSuQmCC"
style={{
maxWidth: '100%',
height: 'auto',
objectFit: 'cover',
}}
loading="lazy"
{...props}
/>
</div>
);
9 changes: 8 additions & 1 deletion src/components/OpenGraph/Artist/OpenGraphDefaultArtist.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/* eslint-disable jsx-a11y/alt-text */
import { Logo } from '@/components/Logo';
import { defaultUserImageURL } from '@/contants';
import formatter from '@/utils/formatter';
import { getOrigin } from '@/utils/ssrUtils';
import type { Artist } from '@/utils/statsfm';
import { isFacebookURL } from '@/utils/urls';
import type Api from '@statsfm/statsfm.js';
import type { NextApiRequest } from 'next';
import type { JSXElementConstructor, ReactElement } from 'react';
Expand All @@ -14,6 +16,11 @@ export function OpenGraphDefaultArtist(
): ReactElement<JSXElementConstructor<any>> {
const origin = getOrigin(req);

let imageURL = artist.image ?? defaultUserImageURL;
if (isFacebookURL(imageURL)) {
imageURL = defaultUserImageURL;
}

return (
<div tw="flex flex-col flex-1 w-full h-full bg-[#18181c]">
<div tw="flex flex-row m-auto pl-32 pr-32">
Expand All @@ -22,7 +29,7 @@ export function OpenGraphDefaultArtist(
tw="rounded-full"
height="400px"
width="400px"
src={`${origin}/api/image?url=${artist.image}&w=256&q=75&f=image/png&fallbackImg=https://cdn.stats.fm/file/statsfm/images/placeholders/users/private.webp`}
src={`${origin}/api/image?url=${encodeURIComponent(imageURL)}&w=256&q=75&f=image/png&fallbackImg=${defaultUserImageURL}`}
/>
</div>
<div
Expand Down
9 changes: 8 additions & 1 deletion src/components/OpenGraph/User/OpenGraphDefaultUser.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/* eslint-disable jsx-a11y/alt-text */
import { Logo } from '@/components/Logo';
import { PlusBadgePrefilled } from '@/components/User/PlusBadge';
import { defaultUserImageURL } from '@/contants';
import { getOrigin } from '@/utils/ssrUtils';
import { splitStringAtLength } from '@/utils/string';
import { isFacebookURL } from '@/utils/urls';
import type { UserPublic } from '@statsfm/statsfm.js';
import type Api from '@statsfm/statsfm.js';
import type { NextApiRequest } from 'next';
Expand All @@ -17,6 +19,11 @@ export function OpenGraphDefaultUser(

const customId = user.customId ?? user.id;

let imageURL = user.image ?? defaultUserImageURL;
if (isFacebookURL(imageURL)) {
imageURL = defaultUserImageURL;
}

return (
<div tw="flex flex-col flex-1 w-[1200px] h-full bg-[#18181c]">
<div tw="flex flex-row m-auto pl-32 pr-32">
Expand All @@ -25,7 +32,7 @@ export function OpenGraphDefaultUser(
tw="rounded-full"
height="400px"
width="400px"
src={`${origin}/api/image?url=${user.image}&w=512&q=75&f=image/png&fallbackImg=https://cdn.stats.fm/file/statsfm/images/placeholders/users/private.webp`}
src={`${origin}/api/image?url=${encodeURIComponent(imageURL)}&w=512&q=75&f=image/png&fallbackImg=${defaultUserImageURL}`}
/>
{user.isPlus && (
<div tw="absolute right-0 bottom-2 flex">
Expand Down
2 changes: 2 additions & 0 deletions src/contants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const defaultUserImageURL =
'https://cdn.stats.fm/file/statsfm/images/placeholders/users/private.webp';
18 changes: 9 additions & 9 deletions src/pages/api/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,13 @@ export async function imageOptimizer(
IMAGE_TYPES.ANIMATABLE.includes(upstreamType) &&
isAnimated(upstreamBuffer)
) {
return {
buffer: upstreamBuffer,
contentType: upstreamType,
maxAge,
};
if (!format || format === upstreamType) {
return {
buffer: upstreamBuffer,
contentType: upstreamType,
maxAge,
};
}
}

if (!upstreamType.startsWith('image/') || upstreamType.includes(',')) {
Expand Down Expand Up @@ -341,6 +343,7 @@ export async function imageOptimizer(
maxAge: Math.max(maxAge, 60),
};
}

throw new ImageError(
400,
'Unable to optimize image and unable to fallback to upstream image',
Expand All @@ -365,10 +368,7 @@ export default async function handler(
if (!isAbsolute)
throw new ImageError(400, '"url" parameter must be an absolute URL');

const imageUpstream = await fetchExternalImage(
encodeURI(href),
fallbackImg,
);
const imageUpstream = await fetchExternalImage(href, fallbackImg);

const {
buffer,
Expand Down
9 changes: 1 addition & 8 deletions src/pages/api/og/artist/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,7 @@ export default async function handler(
return;
}

const image = await renderToImage(
OpenGraphDefaultArtist(req, api, artist),
// {
// debug: true,
// width: 1200,
// height: 600,
// }
);
const image = await renderToImage(OpenGraphDefaultArtist(req, api, artist));

res.setHeader('Content-Type', 'image/png');
res.send(image);
Expand Down
29 changes: 4 additions & 25 deletions src/pages/api/og/user/[id]/[[...variants]].ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,17 @@
import { getApiInstance } from '@/utils/ssrUtils';
import type { ReactElement, JSXElementConstructor } from 'react';
import type { UserPublic } from '@statsfm/statsfm.js';
import type Api from '@statsfm/statsfm.js';
import { OpenGraphDefaultUser } from '@/components/OpenGraph/User/OpenGraphDefaultUser';
import type { NextApiRequest, NextApiResponse } from 'next';
import { renderToImage } from '@/utils/satori';

export const runtime = 'nodejs';

type OGUserHandler = (
req: NextApiRequest,
api: Api,
user: UserPublic,
) => ReactElement<JSXElementConstructor<any>>;

const VARIANTS: Record<string, OGUserHandler> = {
default: OpenGraphDefaultUser,
};

export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { id, variants } = req.query as { id: string; variants: string };
const { id } = req.query as {
id: string;
};

const api = getApiInstance();
const user = await api.users.get(id).catch(() => {});
Expand All @@ -39,17 +28,7 @@ export default async function handler(
user.customId = '';
}

if (
user.image === null ||
user.image === undefined ||
['fbcdn', 'fbsbx'].some((s) => user.image!.includes(s))
)
user.image =
'https://cdn.stats.fm/file/statsfm/images/placeholders/users/private.webp';

const image = await renderToImage(
VARIANTS[variants ?? 'default']!(req, api, user),
);
const image = await renderToImage(OpenGraphDefaultUser(req, api, user));

res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=3600');
Expand Down
4 changes: 3 additions & 1 deletion src/pages/credits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export const getStaticProps = (async () => {
avatarUrl: string;
}> = [];

// const res = await fetch('https://translate-credits.stats.fm');
// const res = await fetch('https://translate-credits.stats.fm', {
// signal: AbortSignal.timeout(5_000),
// });
// const translatorCredits = (await res.json()) as {
// id: number;
// username: string;
Expand Down
6 changes: 2 additions & 4 deletions src/pages/reporting/[type]/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Container } from '@/components/Container';
import { Image } from '@/components/Image';
import { Textarea } from '@/components/Textarea';
import { Title } from '@/components/Title';
import { defaultUserImageURL } from '@/contants';
import { useApi, useToaster } from '@/hooks';
import dayjs from '@/utils/dayjs';
import formatter from '@/utils/formatter';
Expand Down Expand Up @@ -182,10 +183,7 @@ const Reporting: NextPage<Props> = (props) => {
<Avatar src={props.imageUrl} name={props.name} size="4xl" />
) : (
<Image
src={
props.imageUrl ??
'https://cdn.stats.fm/file/statsfm/images/placeholders/users/private.webp'
}
src={props.imageUrl ?? defaultUserImageURL}
alt={props.name}
width={192}
height={192}
Expand Down
3 changes: 2 additions & 1 deletion src/utils/imageLoader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { defaultUserImageURL } from '@/contants';
import type { ImageLoaderProps } from 'next/image';

/*
Expand All @@ -8,5 +9,5 @@ export default function customImageLoader({
width,
quality,
}: ImageLoaderProps) {
return `/api/image?url=${src}&w=${width}&q=${quality ?? 75}`;
return `/api/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality ?? 75}&fallbackImg=${defaultUserImageURL}`;
}
47 changes: 27 additions & 20 deletions src/utils/images/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,30 +184,37 @@ export async function fetchExternalImage(
href: string,
fallbackImg?: string,
): Promise<ImageUpstream> {
let res = await fetch(href);

if (!res.ok) {
if (res.status !== 404)
// eslint-disable-next-line no-console
console.error('upstream image response failed for', href, res.status);
if (fallbackImg) {
res = await fetch(fallbackImg);
return fetch(href, {
signal: AbortSignal.timeout(1_000),
})
.then(async (res) => {
if (!res.ok) {
throw new ImageError(res.status, 'Unable to fetch fallback image');
throw new ImageError(
res.status,
`Upstream image response failed: ${await res.text()}`,
);
}
} else {
throw new ImageError(
res.status,
'"url" parameter is valid but upstream response is invalid',
);
}
}

const buffer = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get('Content-Type');
const cacheControl = res.headers.get('Cache-Control');
const content = await res.arrayBuffer();
const contentType = res.headers.get('Content-Type');
const cacheControl = res.headers.get('Cache-Control');

return {
contentType,
cacheControl,
buffer: Buffer.from(content),
};
})
.catch((error) => {
// eslint-disable-next-line no-console
console.warn(`Unable to fetch external image "${href}":`, String(error));

if (fallbackImg) {
return fetchExternalImage(fallbackImg);
}

return { buffer, contentType, cacheControl };
throw error;
});
}

function parseCacheControl(
Expand Down
2 changes: 2 additions & 0 deletions src/utils/urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const isFacebookURL = (url: string): boolean =>
url.includes('fbcdn') || url.includes('fbsbx');

0 comments on commit 99d38dc

Please sign in to comment.