Skip to content

Commit

Permalink
FEAT: Add IPFS / Pinata avatar upload (#971)
Browse files Browse the repository at this point in the history
* Add IPFS / Pinata avatar upload

* fix build: deprecated config
  • Loading branch information
kirkas authored Aug 29, 2024
1 parent 2fe337b commit e1b1d55
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 80 deletions.
62 changes: 62 additions & 0 deletions apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { pinata } from 'apps/web/src/utils/pinata';
import { NextResponse, NextRequest } from 'next/server';

export const ALLOWED_IMAGE_TYPE = [
'image/svg+xml',
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
];

export const MAX_IMAGE_SIZE_IN_MB = 1; // max 1mb

export async function POST(request: NextRequest) {
try {
// Rerrer validation
const requestUrl = new URL(request.url);

// Username must be provided
const username = requestUrl.searchParams.get('username');
if (!username) return NextResponse.json({ error: 'Invalid request' }, { status: 500 });

// Must have a referer
const referer = request.headers.get('referer');
if (!referer) return NextResponse.json({ error: 'Invalid request' }, { status: 500 });

// referer can only be us
// TODO: Won't work on vercel previews
const refererUrl = new URL(referer);
const allowedReferersHosts = ['localhost:3000', 'base.org'];
if (!allowedReferersHosts.includes(refererUrl.host)) {
return NextResponse.json({ error: 'Invalid request' }, { status: 500 });
}

const data = await request.formData();
const file: File | null = data.get('file') as unknown as File;

// Validation: file is present in request
if (!file) return NextResponse.json({ error: 'No file uploaded' }, { status: 500 });

// Validation: file is an image
if (!ALLOWED_IMAGE_TYPE.includes(file.type))
return NextResponse.json({ error: 'Invalid file type' }, { status: 500 });

// Validation: file is less than 1mb
const bytes = file.size;
const bytesToMegaBytes = bytes / (1024 * 1024);
if (bytesToMegaBytes > MAX_IMAGE_SIZE_IN_MB)
return NextResponse.json({ error: 'File is too large' }, { status: 500 });

// Upload
const uploadData = await pinata.upload.file(file, {
groupId: '765ab5e4-0bc3-47bb-9d6a-35b308291009',
metadata: {
name: username,
},
});
return NextResponse.json(uploadData, { status: 200 });
} catch (e) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
2 changes: 2 additions & 0 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const contentSecurityPolicy = {
'https://translate.googleapis.com', // Let user translate our website
'https://sdk-api.neynar.com/', // Neymar API
'https://unpkg.com/@lottiefiles/[email protected]/dist/dotlottie-player.wasm', // lottie player
`https://${process.env.NEXT_PUBLIC_PINATA_GATEWAY_URL}`,
],
'frame-ancestors': ["'self'", baseXYZDomains],
'form-action': ["'self'", baseXYZDomains],
Expand All @@ -127,6 +128,7 @@ const contentSecurityPolicy = {
'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://${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 @@ -42,6 +42,7 @@
"node-fetch": "^3.3.0",
"permissionless": "^0.1.41",
"pg": "^8.12.0",
"pinata": "^0.4.0",
"react": "^18.2.0",
"react-blockies": "^1.4.1",
"react-copy-to-clipboard": "^5.1.0",
Expand Down
51 changes: 0 additions & 51 deletions apps/web/pages/api/basenames/avatar/upload.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { ActionType } from 'libs/base-ui/utils/logEvent';
import { useCallback, useEffect, useState } from 'react';
import { upload } from '@vercel/blob/client';
import { useAnalytics } from 'apps/web/contexts/Analytics';
import { useErrors } from 'apps/web/contexts/Errors';
import UsernameAvatarField from 'apps/web/src/components/Basenames/UsernameAvatarField';
Expand All @@ -11,6 +10,7 @@ import { Button, ButtonSizes, ButtonVariants } from 'apps/web/src/components/But
import useWriteBaseEnsTextRecords from 'apps/web/src/hooks/useWriteBaseEnsTextRecords';
import { UsernameTextRecordKeys } from 'apps/web/src/utils/usernames';
import { Icon } from 'apps/web/src/components/Icon/Icon';
import { PinResponse } from 'pinata';

export default function UsernameProfileSettingsAvatar() {
const { profileUsername, profileAddress, currentWalletIsProfileEditor } = useUsernameProfile();
Expand All @@ -37,30 +37,37 @@ export default function UsernameProfileSettingsAvatar() {
},
});

const uploadAvatar = useCallback(
const uploadFile = useCallback(
async (file: File | undefined) => {
if (!file) return Promise.resolve();
if (!currentWalletIsProfileEditor) return false;

setAvatarIsLoading(true);

logEventWithContext('avatar_upload_initiated', ActionType.change);

const timestamp = Date.now();
const newBlob = await upload(
`basenames/avatar/${profileUsername}/${timestamp}/${file.name}`,
file,
{
access: 'public',
handleUploadUrl: `/api/basenames/avatar/upload?username=${profileUsername}`,
},
);
setAvatarIsLoading(false);
updateTextRecords(UsernameTextRecordKeys.Avatar, newBlob.url);

return newBlob;
try {
setAvatarIsLoading(true);
const data = new FormData();
data.set('file', file);
const uploadRequest = await fetch(
`/api/basenames/avatar/ipfsUpload?username=${profileUsername}`,
{
method: 'POST',
body: data,
},
);

if (uploadRequest.ok) {
const uploadData = (await uploadRequest.json()) as PinResponse;
updateTextRecords(UsernameTextRecordKeys.Avatar, `ipfs://${uploadData.IpfsHash}`);
setAvatarIsLoading(false);
return uploadData;
} else {
alert(uploadRequest.statusText);
logError(uploadRequest, 'Failed to upload Avatar');
setAvatarIsLoading(false);
}
} catch (e) {
alert('Trouble uploading file');
}
},
[currentWalletIsProfileEditor, logEventWithContext, profileUsername, updateTextRecords],
[logError, profileUsername, updateTextRecords],
);

const saveAvatar = useCallback(() => {
Expand All @@ -79,7 +86,7 @@ export default function UsernameProfileSettingsAvatar() {
if (!currentWalletIsProfileEditor) return false;

if (avatarFile) {
uploadAvatar(avatarFile)
uploadFile(avatarFile)
.then((result) => {
// set the uploaded result as the url
if (result) {
Expand All @@ -98,7 +105,7 @@ export default function UsernameProfileSettingsAvatar() {
[
currentWalletIsProfileEditor,
avatarFile,
uploadAvatar,
uploadFile,
logEventWithContext,
logError,
saveAvatar,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/utils/pinata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { PinataSDK } from 'pinata';

export const pinata = new PinataSDK({
pinataJwt: `${process.env.PINATA_API_KEY}`,
pinataGateway: `${process.env.NEXT_PUBLIC_PINATA_GATEWAY_URL}`,
});
4 changes: 3 additions & 1 deletion apps/web/src/utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { cid } from 'is-ipfs';
export type IpfsUrl = `ipfs://${string}`;
export const VERCEL_BLOB_HOSTNAME = 'zku9gdedgba48lmr.public.blob.vercel-storage.com';
export const IPFS_URI_PROTOCOL = 'ipfs://';
export const CLOUDFARE_IPFS_PROXY = 'https://cloudflare-ipfs.com';
export const CLOUDFARE_IPFS_PROXY = process.env.NEXT_PUBLIC_PINATA_GATEWAY_URL
? `https://${process.env.NEXT_PUBLIC_PINATA_GATEWAY_URL}`
: 'https://cloudflare-ipfs.com';

export type QueryParams = Record<string, string>;

Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/utils/usernames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@ import {
USERNAME_EA_REGISTRAR_CONTROLLER_ADDRESSES,
USERNAME_REGISTRAR_CONTROLLER_ADDRESSES,
} from 'apps/web/src/addresses/usernames';
import {
ALLOWED_IMAGE_TYPE,
MAX_IMAGE_SIZE_IN_MB,
} from 'apps/web/pages/api/basenames/avatar/upload';

import {
getIpfsGatewayUrl,
IpfsUrl,
Expand Down Expand Up @@ -57,6 +54,10 @@ import image6 from 'apps/web/src/components/Basenames/BasenameAvatar/images/6.sv
import image7 from 'apps/web/src/components/Basenames/BasenameAvatar/images/7.svg';

import { StaticImageData } from 'next/image';
import {
ALLOWED_IMAGE_TYPE,
MAX_IMAGE_SIZE_IN_MB,
} from 'apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route';

export const USERNAME_MIN_CHARACTER_LENGTH = 3;
export const USERNAME_MAX_CHARACTER_LENGTH = 20;
Expand Down
13 changes: 12 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ __metadata:
node-fetch: ^3.3.0
permissionless: ^0.1.41
pg: ^8.12.0
pinata: ^0.4.0
postcss: ^8.4.21
prettier-plugin-tailwindcss: ^0.2.5
react: ^18.2.0
Expand Down Expand Up @@ -19495,7 +19496,7 @@ __metadata:
languageName: node
linkType: hard

"node-fetch@npm:^3.3.0":
"node-fetch@npm:^3.3.0, node-fetch@npm:^3.3.1":
version: 3.3.2
resolution: "node-fetch@npm:3.3.2"
dependencies:
Expand Down Expand Up @@ -20510,6 +20511,16 @@ __metadata:
languageName: node
linkType: hard

"pinata@npm:^0.4.0":
version: 0.4.0
resolution: "pinata@npm:0.4.0"
dependencies:
is-ipfs: ^8.0.4
node-fetch: ^3.3.1
checksum: 698505682f20e644cfe16c7ba4117e1e0d926408cd9a0946a75981d6aa554a73afca7fa7ad1b5ecd85c79411f03845f6f1e35ca633ce874a450ddc4d80496a71
languageName: node
linkType: hard

"pino-abstract-transport@npm:v0.5.0":
version: 0.5.0
resolution: "pino-abstract-transport@npm:0.5.0"
Expand Down

0 comments on commit e1b1d55

Please sign in to comment.