Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Cloudinary integration #1071

Merged
merged 13 commits into from
Oct 15, 2024
109 changes: 109 additions & 0 deletions apps/web/app/api/cloudinaryUrl/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { v2 as cloudinary } from 'cloudinary';
import { createHash } from 'crypto';

// 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
console.error('Error checking if asset exists in Cloudinary:', error);
kirkas marked this conversation as resolved.
Show resolved Hide resolved
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) {
console.log('Failed to upload asset', error);
kirkas marked this conversation as resolved.
Show resolved Hide resolved
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) {
console.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,42 +89,37 @@ 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',
{ 'opacity-0': !isLoading || !isTransitioning, 'opacity-100': isLoading },
)}
>
<Image src={baseLoading} alt="" width={22} height={22} />
</div>
{isLoading && (
<div
kirkas marked this conversation as resolved.
Show resolved Hide resolved
className={classNames(
'absolute inset-0 flex items-center justify-center transition-opacity duration-500',
{ 'opacity-0': !isLoading || !isTransitioning, 'opacity-100': isLoading },
)}
>
<Image src={baseLoading} alt="" width={22} height={22} />
</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
Loading