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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading