Skip to content

Commit

Permalink
Feat: Cloudinary integration (#1071)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update apps/web/src/components/ImageCloudinary/index.tsx

Co-authored-by: Jordan Frankfurt <[email protected]>

* add logger to the routes

* env example

* fix loading

---------

Co-authored-by: Jordan Frankfurt <[email protected]>
  • Loading branch information
kirkas and JFrankfurt authored Oct 15, 2024
1 parent 46005fd commit 9a52003
Show file tree
Hide file tree
Showing 14 changed files with 328 additions and 114 deletions.
6 changes: 5 additions & 1 deletion apps/web/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@ FARCASTER_DEVELOPER_MNEMONIC=

ETHERSCAN_API_KEY=
BASESCAN_API_KEY=
TALENT_PROTOCOL_API_KEY=
TALENT_PROTOCOL_API_KEY=

NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
110 changes: 110 additions & 0 deletions apps/web/app/api/cloudinaryUrl/route.ts
Original file line number Diff line number Diff line change
@@ -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<string | false> {
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<string | false> {
// 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 });
}
}
36 changes: 0 additions & 36 deletions apps/web/app/frames/img-proxy/route.ts

This file was deleted.

24 changes: 0 additions & 24 deletions apps/web/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
],
};
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export default function UsernameAvatarField({
imageClassName="object-cover h-full w-full"
width={320}
height={320}
useCloudinary={false}
/>
<FileInput
id={usernameAvatarFieldId}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable @next/next/no-img-element */
/* eslint-disable react/prop-types */
import type { FrameUIComponents, FrameUITheme } from '@frames.js/render/ui';
import classNames from 'classnames';
import Image from 'next/image';
import { useEffect, useMemo, useState } from 'react';
import baseLoading from './base-loading.gif';
import ImageCloudinary from 'apps/web/src/components/ImageCloudinary';
import { ExclamationCircleIcon } from '@heroicons/react/16/solid';

type StylingProps = {
Expand Down Expand Up @@ -43,13 +43,8 @@ export const theme: FrameUITheme<StylingProps> = {
},
};

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';
Expand Down Expand Up @@ -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 (
<div className="relative">
{/* Loading Screen */}

<div
className={classNames(
'absolute inset-0 flex items-center justify-center transition-opacity duration-500',
Expand All @@ -117,19 +103,22 @@ function TransitionWrapper({
</div>

{/* Image */}
<img
{...stylingProps}
src={assetSrc}
alt={alt}
onLoad={onImageLoadEnd}
onError={onImageLoadEnd}
data-aspect-ratio={ar}
style={style}
className={classNames('transition-opacity duration-500', {
'opacity-0': isLoading || isTransitioning,
'opacity-100': !isLoading && !isTransitioning,
})}
/>
{src && (
<ImageCloudinary
{...stylingProps}
src={src}
alt={alt}
width={maxFrameImageWidth}
onLoad={onImageLoadEnd}
onError={onImageLoadEnd}
data-aspect-ratio={ar}
style={style}
className={classNames('transition-opacity duration-500', {
'opacity-0': isLoading || isTransitioning,
'opacity-100': !isLoading && !isTransitioning,
})}
/>
)}
</div>
);
}
Expand Down
54 changes: 38 additions & 16 deletions apps/web/src/components/ImageAdaptive/index.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -20,25 +22,45 @@ export default function ImageAdaptive({
quality,
style,
fill,
useCloudinary = true,
}: ImageAdaptiveProps) {
const useNextImage = shouldUseNextImage(src);

return useNextImage ? (
<Image
src={src}
className={className}
alt={alt}
title={title}
placeholder={placeholder}
onLoad={onLoad}
width={width}
height={height}
quality={quality}
style={style}
priority={priority}
fill={fill}
/>
) : (
if (useNextImage) {
return (
<Image
src={src}
className={className}
alt={alt}
title={title}
placeholder={placeholder}
onLoad={onLoad}
width={width}
height={height}
quality={quality}
style={style}
priority={priority}
fill={fill}
/>
);
}

if (useCloudinary) {
return (
<ImageCloudinary
src={src}
className={className}
alt={alt}
title={title}
onLoad={onLoad}
width={width}
height={height}
style={style}
/>
);
}

return (
<ImageRaw
src={src}
className={className}
Expand Down
Loading

0 comments on commit 9a52003

Please sign in to comment.