From 9a520033dbd5b76fb37ce9e7d25a7dc1f60720e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Tue, 15 Oct 2024 12:43:20 -0400 Subject: [PATCH] Feat: Cloudinary integration (#1071) * cloudinary integration wip * cloudinary integration wip * fetch & proxy * fix lock * remove lib due to wasm nonsense * clean up * Update apps/web/src/components/ImageCloudinary/index.tsx Co-authored-by: Jordan Frankfurt * Update apps/web/src/components/ImageCloudinary/index.tsx Co-authored-by: Jordan Frankfurt * add logger to the routes * env example * fix loading --------- Co-authored-by: Jordan Frankfurt --- apps/web/.env.local.example | 6 +- apps/web/app/api/cloudinaryUrl/route.ts | 110 ++++++++++++++++++ apps/web/app/frames/img-proxy/route.ts | 36 ------ apps/web/middleware.ts | 24 ---- apps/web/next.config.js | 2 +- apps/web/package.json | 1 + .../Basenames/UsernameAvatarField/index.tsx | 1 + .../FrameTheme.tsx | 51 ++++---- .../src/components/ImageAdaptive/index.tsx | 54 ++++++--- .../src/components/ImageCloudinary/index.tsx | 104 +++++++++++++++++ .../src/components/ImageWithLoading/index.tsx | 3 + apps/web/src/utils/images.ts | 28 ++++- apps/web/src/utils/urls.ts | 4 + yarn.lock | 18 +++ 14 files changed, 328 insertions(+), 114 deletions(-) create mode 100644 apps/web/app/api/cloudinaryUrl/route.ts delete mode 100644 apps/web/app/frames/img-proxy/route.ts create mode 100644 apps/web/src/components/ImageCloudinary/index.tsx diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index 06298bfcf3..d93ee005ca 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -27,4 +27,8 @@ FARCASTER_DEVELOPER_MNEMONIC= ETHERSCAN_API_KEY= BASESCAN_API_KEY= -TALENT_PROTOCOL_API_KEY= \ No newline at end of file +TALENT_PROTOCOL_API_KEY= + +NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= \ No newline at end of file diff --git a/apps/web/app/api/cloudinaryUrl/route.ts b/apps/web/app/api/cloudinaryUrl/route.ts new file mode 100644 index 0000000000..cb0ea4f52a --- /dev/null +++ b/apps/web/app/api/cloudinaryUrl/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { v2 as cloudinary } from 'cloudinary'; +import { createHash } from 'crypto'; +import { logger } from 'apps/web/src/utils/logger'; + +// Configure Cloudinary +cloudinary.config({ + cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +const folderName = 'base-org-uploads'; + +export type CloudinaryMediaUrlRequest = { + media: string; + width: number; +}; + +export type CloudinaryMediaUrlResponse = { + url: string; +}; + +function generateAssetId(media: string): string { + return createHash('md5').update(media).digest('hex'); +} + +async function checkAssetExists(assetId: string): Promise { + try { + const response = await cloudinary.api.resources_by_ids([`${folderName}/${assetId}`]); + if (response && response.resources.length > 0) { + const image = response.resources[0]; + return image.secure_url; + } else { + return false; + } + } catch (error) { + // For other errors, log and assume the asset doesn't exist + logger.error('Error checking if asset exists in Cloudinary', error, { assetId }); + return false; + } +} + +async function uploadToCloudinary(media: string, width: number) { + try { + const assetId = generateAssetId(media); + + // Otherwise upload it to group + const result = await cloudinary.uploader.upload(media, { + public_id: assetId, + folder: folderName, + format: 'webp', + transformation: { + width: width, + }, + }); + + return result; + } catch (error) { + logger.error('Failed to upload asset', error, { media }); + return false; + } +} + +async function getCloudinaryMediaUrl({ + media, + width, +}: CloudinaryMediaUrlRequest): Promise { + // Asset idea based on URL + const assetId = generateAssetId(media); + + // Return the asset if already uploaded + const existingAssetUrl = await checkAssetExists(assetId); + if (existingAssetUrl) { + return existingAssetUrl; + } + + const cloudinaryUpload = await uploadToCloudinary(media, width); + + if (cloudinaryUpload) { + return cloudinaryUpload.secure_url; + } + + return false; +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as CloudinaryMediaUrlRequest; + + const { media, width } = body; + + if (!media || !width) { + return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); + } + + const cloudinaryUrl = await getCloudinaryMediaUrl({ media, width }); + if (cloudinaryUrl) { + const response: CloudinaryMediaUrlResponse = { + url: cloudinaryUrl, + }; + return NextResponse.json(response); + } else { + return NextResponse.json({ error: 'Failed to upload Cloudinary URL' }, { status: 500 }); + } + } catch (error) { + logger.error('Error processing Cloudinary URL:', error); + return NextResponse.json({ error: 'Failed to process Cloudinary URL' }, { status: 500 }); + } +} diff --git a/apps/web/app/frames/img-proxy/route.ts b/apps/web/app/frames/img-proxy/route.ts deleted file mode 100644 index 0a07234a93..0000000000 --- a/apps/web/app/frames/img-proxy/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { withIPCheck } from '../proxy-ip-check'; - -async function getHandler(request: NextRequest) { - const { searchParams } = new URL(request.url); - const url = searchParams.get('url'); - - if (!url) { - return NextResponse.json({ error: 'Missing url' }, { status: 400 }); - } - - try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.statusText}`); - } - const contentType = response.headers.get('content-type'); - - if (!contentType || !contentType.startsWith('image') || contentType.includes('svg')) { - return NextResponse.json({ error: 'Unsupported content type' }, { status: 400 }); - } - - const imageBuffer = await response.arrayBuffer(); - return new NextResponse(imageBuffer, { - status: 200, - headers: { - 'Content-Type': contentType ?? 'application/octet-stream', - 'Cache-Control': 'public, max-age=86400', - }, - }); - } catch (error) { - return NextResponse.json({ error: 'Failed to fetch image' }, { status: 500 }); - } -} - -export const GET = withIPCheck(getHandler); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 17364b150d..5a302266dc 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -22,30 +22,6 @@ export function middleware(req: NextRequest) { return NextResponse.redirect(url); } - // Open img and media csp on username profile to support frames - if (url.pathname.startsWith('/name/')) { - const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); - - // Open image src - const cspHeader = ` - img-src 'self' https: data: blob:; - media-src 'self' https: data: blob:; - `; - - const contentSecurityPolicyHeaderValue = cspHeader.replace(/\s{2,}/g, ' ').trim(); - const requestHeaders = new Headers(req.headers); - requestHeaders.set('x-nonce', nonce); - requestHeaders.set('Content-Security-Policy', contentSecurityPolicyHeaderValue); - const response = NextResponse.next({ - request: { - headers: requestHeaders, - }, - }); - response.headers.set('Content-Security-Policy', contentSecurityPolicyHeaderValue); - - return response; - } - if (url.pathname === '/guides/run-a-base-goerli-node') { url.host = 'docs.base.org'; url.pathname = '/tutorials/run-a-base-node'; diff --git a/apps/web/next.config.js b/apps/web/next.config.js index bac6149d59..9f259d7395 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -130,7 +130,7 @@ const contentSecurityPolicy = { 'https://i.seadn.io/', // ens avatars 'https://ipfs.io', // ipfs ens avatar resolution 'https://cloudflare-ipfs.com', // ipfs Cloudfare ens avatar resolution - 'https://zku9gdedgba48lmr.public.blob.vercel-storage.com', // basename avatar upload to vercel blob + 'https://res.cloudinary.com', `https://${process.env.NEXT_PUBLIC_PINATA_GATEWAY_URL}`, ], }; diff --git a/apps/web/package.json b/apps/web/package.json index 420facd84f..7f567ff333 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,6 +44,7 @@ "@vercel/postgres-kysely": "^0.8.0", "base-ui": "0.1.1", "classnames": "^2.5.1", + "cloudinary": "^2.5.1", "dd-trace": "^5.21.0", "ethers": "5.7.2", "framer-motion": "^11.9.0", diff --git a/apps/web/src/components/Basenames/UsernameAvatarField/index.tsx b/apps/web/src/components/Basenames/UsernameAvatarField/index.tsx index 2fe6498aea..9efd118f9b 100644 --- a/apps/web/src/components/Basenames/UsernameAvatarField/index.tsx +++ b/apps/web/src/components/Basenames/UsernameAvatarField/index.tsx @@ -134,6 +134,7 @@ export default function UsernameAvatarField({ imageClassName="object-cover h-full w-full" width={320} height={320} + useCloudinary={false} /> = { }, }; -function isDataUrl(url: string) { - return /^data:image\/[a-zA-Z]+;base64,/.test(url); -} - -function isSvgDataUrl(url: string) { - return url.startsWith('data:image/svg+xml'); -} +// Image is never displayed with a higher width +const maxFrameImageWidth = 775; type TransitionWrapperProps = { aspectRatio: '1:1' | '1.91:1'; @@ -94,19 +89,10 @@ function TransitionWrapper({ [ar, stylingProps.style], ); - const assetSrc = useMemo( - () => - isLoading || isSvgDataUrl(src) - ? '' // todo: in the svg case, add an error state instead - : isDataUrl(src) - ? src - : `/frames/img-proxy?url=${encodeURIComponent(src)}`, - [isLoading, src], - ); - return (
{/* Loading Screen */} +
{/* Image */} - {alt} + {src && ( + + )}
); } diff --git a/apps/web/src/components/ImageAdaptive/index.tsx b/apps/web/src/components/ImageAdaptive/index.tsx index 9d2630179f..2780c24268 100644 --- a/apps/web/src/components/ImageAdaptive/index.tsx +++ b/apps/web/src/components/ImageAdaptive/index.tsx @@ -1,10 +1,12 @@ import Image, { ImageProps, StaticImageData } from 'next/image'; import { shouldUseNextImage } from 'apps/web/src/utils/images'; import ImageRaw from 'apps/web/src/components/ImageRaw'; +import ImageCloudinary from 'apps/web/src/components/ImageCloudinary'; type ImageAdaptiveProps = ImageProps & { // Fix next's js bad import src: string | StaticImageData; + useCloudinary?: boolean; }; export default function ImageAdaptive({ @@ -20,25 +22,45 @@ export default function ImageAdaptive({ quality, style, fill, + useCloudinary = true, }: ImageAdaptiveProps) { const useNextImage = shouldUseNextImage(src); - return useNextImage ? ( - {alt} - ) : ( + if (useNextImage) { + return ( + {alt} + ); + } + + if (useCloudinary) { + return ( + + ); + } + + return ( ; + onError?: React.ReactEventHandler; + style?: CSSProperties; +}; + +// This image component is to purposefully avoid loading images via base.org +export default function ImageCloudinary({ + src, + alt, + title, + width = 1200, // realistically, no image needs to be higher res + height, + className, + onLoad, + onError, + style, +}: ImageCloudinaryProps) { + const absoluteSrc = getImageAbsoluteSource(src); + + const [cloudinaryUploadUrl, setCloudinaryUploadUrl] = useState(null); + + const cloudinaryFetchUrl = getCloudinaryMediaUrl({ + media: absoluteSrc, + width: Number(width), + }); + + const shouldUploadToCloudinary = isDataUrl(absoluteSrc) || absoluteSrc.length > 255; + + useEffect(() => { + // Some images need to be uploaded before being proxied + // dataUrl and long Urls need to be uploaded to Cloudinary + + // ref: https://support.cloudinary.com/hc/en-us/articles/209209649-Does-Cloudinary-impose-a-URL-length-limit + if (shouldUploadToCloudinary) { + async function handleGetCloudinaryUrl() { + try { + const response = await fetch('/api/cloudinaryUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + media: absoluteSrc, + width: width, + }), + }); + + if (!response.ok) { + throw new Error('Failed to get Cloudinary URL'); + } + + const data = (await response.json()) as CloudinaryMediaUrlResponse; + const url = data?.url; + if (url) { + setCloudinaryUploadUrl(url); + } + } catch (error) { + console.error('Error getting Cloudinary URL:', error); + } + } + + handleGetCloudinaryUrl() + .then() + .catch((error) => console.log(error)); + } + }, [absoluteSrc, shouldUploadToCloudinary, width]); + + // Image needs to be manually upload/proxied tru cloudinary + if (shouldUploadToCloudinary && !cloudinaryUploadUrl) { + return null; + } + + const imgSrc = cloudinaryUploadUrl ?? cloudinaryFetchUrl; + + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ); +} diff --git a/apps/web/src/components/ImageWithLoading/index.tsx b/apps/web/src/components/ImageWithLoading/index.tsx index 45005c686c..cead3633be 100644 --- a/apps/web/src/components/ImageWithLoading/index.tsx +++ b/apps/web/src/components/ImageWithLoading/index.tsx @@ -15,6 +15,7 @@ type ImageWithLoadingProps = { backgroundClassName?: string; imageClassName?: string; forceIsLoading?: boolean; + useCloudinary?: boolean; }; export default function ImageWithLoading({ @@ -27,6 +28,7 @@ export default function ImageWithLoading({ backgroundClassName = 'bg-gray-10/50', imageClassName, forceIsLoading = false, + useCloudinary = true, }: ImageWithLoadingProps) { const [imageIsLoading, setImageIsLoading] = useState(true); @@ -60,6 +62,7 @@ export default function ImageWithLoading({ width={width} height={height} quality={100} + useCloudinary={useCloudinary} /> ); diff --git a/apps/web/src/utils/images.ts b/apps/web/src/utils/images.ts index f66167cd68..79be99438d 100644 --- a/apps/web/src/utils/images.ts +++ b/apps/web/src/utils/images.ts @@ -1,6 +1,6 @@ -import { IsValidVercelBlobUrl } from 'apps/web/src/utils/urls'; import { StaticImageData } from 'next/image'; +export const CLOUDINARY_CLOUD_NAME = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME; export const STATIC_IMAGE_FOLDER = '/_next/static/'; export const PUBLIC_IMAGE_FOLDER = '/images/'; @@ -19,10 +19,28 @@ export const shouldUseNextImage = (src: string | StaticImageData): boolean => { const isPublicImage = absoluteImageSource.startsWith(PUBLIC_IMAGE_FOLDER); if (isPublicImage) return true; - // Vercel blolb images (Basename Avatar) - const isVercelBlobImage = IsValidVercelBlobUrl(absoluteImageSource); - if (isVercelBlobImage) return true; - // Any other image, don't load via base.org / nextjs image proxy return false; }; + +function isDataUrl(url: string) { + return /^data:image\/[a-zA-Z]+;base64,/.test(url); +} + +type GetCloudinaryMediaUrlParams = { + media: string; + width: number; +}; + +export function getCloudinaryMediaUrl({ media, width }: GetCloudinaryMediaUrlParams) { + if (isDataUrl(media)) return media; + + const imageWidth = `w_${width * 2}`; + const imageFormat = 'f_webp'; + const imageUrl = encodeURI(media); + const fetchOptions = [imageWidth, imageFormat, imageUrl].join('/'); + + const url = `https://res.cloudinary.com/${CLOUDINARY_CLOUD_NAME}/image/fetch/${fetchOptions}`; + + return url; +} diff --git a/apps/web/src/utils/urls.ts b/apps/web/src/utils/urls.ts index 67fe659da1..2bcd097590 100644 --- a/apps/web/src/utils/urls.ts +++ b/apps/web/src/utils/urls.ts @@ -66,3 +66,7 @@ export const getIpfsGatewayUrl = (ipfsUrl?: IpfsUrl): string | undefined => { return; } }; + +export function isDataUrl(url: string): boolean { + return url.startsWith('data:'); +} diff --git a/yarn.lock b/yarn.lock index 9451368cb2..8b4d622b26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -399,6 +399,7 @@ __metadata: autoprefixer: ^10.4.13 base-ui: 0.1.1 classnames: ^2.5.1 + cloudinary: ^2.5.1 csv-parser: ^3.0.0 dd-trace: ^5.21.0 dotenv: ^16.0.3 @@ -12116,6 +12117,16 @@ __metadata: languageName: node linkType: hard +"cloudinary@npm:^2.5.1": + version: 2.5.1 + resolution: "cloudinary@npm:2.5.1" + dependencies: + lodash: ^4.17.21 + q: ^1.5.1 + checksum: 4e4a09fe37677fce1051371b8906bf3a28f31017b867701e7c51977b6bcd0185603f0ca2e47052730e5691b5d0d40abd7f7d7774c046784b1ba55b390b687493 + languageName: node + linkType: hard + "clsx@npm:2.1.0": version: 2.1.0 resolution: "clsx@npm:2.1.0" @@ -22663,6 +22674,13 @@ __metadata: languageName: node linkType: hard +"q@npm:^1.5.1": + version: 1.5.1 + resolution: "q@npm:1.5.1" + checksum: 147baa93c805bc1200ed698bdf9c72e9e42c05f96d007e33a558b5fdfd63e5ea130e99313f28efc1783e90e6bdb4e48b67a36fcc026b7b09202437ae88a1fb12 + languageName: node + linkType: hard + "qr-code-styling@npm:^1.6.0-rc.1": version: 1.6.0-rc.1 resolution: "qr-code-styling@npm:1.6.0-rc.1"