From 7fdb836ec4aa5d5fd6d7ef45a304a581c244a825 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Thu, 19 Dec 2024 15:43:56 +0200 Subject: [PATCH 01/10] IPFS PFP Upload Signed-off-by: prxt6529 --- components/ipfs/IPFSContext.tsx | 58 +++++++++++++ components/ipfs/IPFSService.ts | 84 ++++++++++++++++++ .../pfp/UserPageHeaderEditPfp.tsx | 86 ++++++++++++++----- helpers/image.helpers.ts | 2 - next.config.js | 4 + package-lock.json | 6 +- pages/_app.tsx | 53 +++++++++--- 7 files changed, 257 insertions(+), 36 deletions(-) create mode 100644 components/ipfs/IPFSContext.tsx create mode 100644 components/ipfs/IPFSService.ts diff --git a/components/ipfs/IPFSContext.tsx b/components/ipfs/IPFSContext.tsx new file mode 100644 index 000000000..9abd4719d --- /dev/null +++ b/components/ipfs/IPFSContext.tsx @@ -0,0 +1,58 @@ +import React, { createContext, useContext, useEffect } from "react"; +import IpfsService from "./IPFSService"; + +interface IpfsContextType { + ipfsService: IpfsService; +} + +const IpfsContext = createContext(undefined); + +const getEnv = () => { + const domain = process.env.IPFS_DOMAIN; + const rpcPort = process.env.IPFS_RPC_PORT; + const gatewayPort = process.env.IPFS_GATEWAY_PORT; + const mfsPath = process.env.IPFS_MFS_PATH; + + if (!domain || !rpcPort || !gatewayPort || !mfsPath) { + throw new Error( + "IPFS_DOMAIN, IPFS_RPC_PORT, IPFS_GATEWAY_PORT, and IPFS_MFS_PATH must be set" + ); + } + + return { domain, rpcPort, gatewayPort, mfsPath }; +}; + +export const IpfsProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { domain, rpcPort, gatewayPort, mfsPath } = getEnv(); + + const ipfsService = new IpfsService({ + baseDomain: domain, + rpcPort: parseInt(rpcPort), + gatewayPort: parseInt(gatewayPort), + mfsPath: `/${mfsPath}`, + }); + + return ( + + {children} + + ); +}; + +export const useIpfsService = (): IpfsService => { + const context = useContext(IpfsContext); + if (!context) { + throw new Error("useIpfsService must be used within an IpfsProvider"); + } + return context.ipfsService; +}; + +export const useIpfsGatewayUrl = (url: string) => { + if (url.startsWith("ipfs://")) { + const { domain, gatewayPort } = getEnv(); + return `${domain}:${gatewayPort}/ipfs/${url.slice(7)}`; + } + return url; +}; diff --git a/components/ipfs/IPFSService.ts b/components/ipfs/IPFSService.ts new file mode 100644 index 000000000..6c9d05307 --- /dev/null +++ b/components/ipfs/IPFSService.ts @@ -0,0 +1,84 @@ +import axios from "axios"; +import * as path from "path"; +import FormData from "form-data"; +import { getRandomObjectId } from "../../helpers/AllowlistToolHelpers"; + +interface IpfsServiceConfig { + baseDomain: string; + rpcPort: number; + gatewayPort: number; + mfsPath: string; +} + +class IpfsService { + private baseDomain: string; + private rpcPort: number; + private gatewayPort: number; + private mfsPath: string; + + constructor(config: IpfsServiceConfig) { + this.baseDomain = config.baseDomain; + this.rpcPort = config.rpcPort; + this.gatewayPort = config.gatewayPort; + this.mfsPath = config.mfsPath; + + this.init(); + } + + private async init() { + try { + await axios.post( + `${this.baseDomain}:${this.rpcPort}/api/v0/files/mkdir?arg=${this.mfsPath}&parents=true` + ); + } catch (error: any) { + console.error("Failed to configure MFS:", error.message); + throw error; + } + } + + private createFormData(file: File): FormData { + const formData = new FormData(); + formData.append("file", file); + return formData; + } + + private async validateFileName(cid: string, file: File) { + const extension = file.name.split(".").pop(); + const fileName = `${cid}.${extension}`; + + try { + await axios.post( + `${this.baseDomain}:${this.rpcPort}/api/v0/files/stat?arg=${this.mfsPath}/${fileName}` + ); + console.log(`File ${fileName} already exists.`); + } catch (error: any) { + await axios.post( + `${this.baseDomain}:${this.rpcPort}/api/v0/files/cp?arg=/ipfs/${cid}&arg=${this.mfsPath}/${fileName}` + ); + console.log(`File added to MFS at ${this.mfsPath}/${fileName}`); + } + } + + async addFile(file: File): Promise { + try { + const formData = this.createFormData(file); + + const addResponse = await axios.post( + `${this.baseDomain}:${this.rpcPort}/api/v0/add?pin=true`, + formData + ); + + const cid = addResponse.data.Hash; + console.log("File added to IPFS with CID:", cid); + + await this.validateFileName(cid, file); + + return cid; + } catch (error: any) { + console.error("Failed to add file to IPFS or MFS:", error.message); + throw error; + } + } +} + +export default IpfsService; diff --git a/components/user/user-page-header/pfp/UserPageHeaderEditPfp.tsx b/components/user/user-page-header/pfp/UserPageHeaderEditPfp.tsx index 86aceb38b..74d2bbb57 100644 --- a/components/user/user-page-header/pfp/UserPageHeaderEditPfp.tsx +++ b/components/user/user-page-header/pfp/UserPageHeaderEditPfp.tsx @@ -1,14 +1,30 @@ -import {FormEvent, useContext, useEffect, useRef, useState} from "react"; -import {IProfileAndConsolidations} from "../../../../entities/IProfile"; -import {useClickAway, useKeyPressEvent} from "react-use"; -import UserSettingsImgSelectMeme, {MemeLite} from "../../settings/UserSettingsImgSelectMeme"; +import { FormEvent, useContext, useEffect, useRef, useState } from "react"; +import { + ApiCreateOrUpdateProfileRequest, + IProfileAndConsolidations, +} from "../../../../entities/IProfile"; +import { useClickAway, useKeyPressEvent } from "react-use"; +import UserSettingsImgSelectMeme, { + MemeLite, +} from "../../settings/UserSettingsImgSelectMeme"; import UserSettingsImgSelectFile from "../../settings/UserSettingsImgSelectFile"; import UserSettingsSave from "../../settings/UserSettingsSave"; -import {AuthContext} from "../../../auth/Auth"; -import {QueryKey, ReactQueryWrapperContext,} from "../../../react-query-wrapper/ReactQueryWrapper"; -import {commonApiFetch, commonApiPostForm,} from "../../../../services/api/common-api"; -import {useMutation, useQuery} from "@tanstack/react-query"; -import {getScaledImageUri, ImageScale} from "../../../../helpers/image.helpers"; +import { AuthContext } from "../../../auth/Auth"; +import { + QueryKey, + ReactQueryWrapperContext, +} from "../../../react-query-wrapper/ReactQueryWrapper"; +import { + commonApiFetch, + commonApiPost, + commonApiPostForm, +} from "../../../../services/api/common-api"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + getScaledImageUri, + ImageScale, +} from "../../../../helpers/image.helpers"; +import { useIpfsService } from "../../../ipfs/IPFSContext"; export default function UserPageHeaderEditPfp({ profile, @@ -21,6 +37,8 @@ export default function UserPageHeaderEditPfp({ useClickAway(modalRef, onClose); useKeyPressEvent("Escape", onClose); + const ipfsService = useIpfsService(); + const { setToast, requestAuth } = useContext(AuthContext); const { onProfileEdit } = useContext(ReactQueryWrapperContext); @@ -40,7 +58,9 @@ export default function UserPageHeaderEditPfp({ }); const [imageToShow, setImageToShow] = useState( - (profile.profile?.pfp_url ? getScaledImageUri(profile.profile.pfp_url, ImageScale.W_200_H_200) : null) + profile.profile?.pfp_url + ? getScaledImageUri(profile.profile.pfp_url, ImageScale.W_200_H_200) + : null ); const [selectedMeme, setSelectedMeme] = useState(null); @@ -69,18 +89,45 @@ export default function UserPageHeaderEditPfp({ const updatePfp = useMutation({ mutationFn: async (body: FormData) => { setSaving(true); - return await commonApiPostForm<{ pfp_url: string }>({ - endpoint: `profiles/${profile.input_identity}/pfp`, - body: body, - }); + const pfp = body.get("pfp"); + if (pfp) { + if (!profile.profile?.classification) { + return; + } + const cid = await ipfsService.addFile(pfp as File); + const ipfs = `ipfs://${cid}`; + const ipfsBody: ApiCreateOrUpdateProfileRequest = { + handle: profile.profile?.handle, + classification: profile.profile?.classification, + pfp_url: ipfs, + }; + if (profile.profile?.banner_1) { + ipfsBody.banner_1 = ipfs; + } + if (profile.profile?.banner_2) { + ipfsBody.banner_2 = ipfs; + } + const response = await commonApiPost< + ApiCreateOrUpdateProfileRequest, + IProfileAndConsolidations + >({ + endpoint: `profiles`, + body: ipfsBody, + }); + return response.profile?.pfp_url; + } else { + const response = await commonApiPostForm<{ pfp_url: string }>({ + endpoint: `profiles/${profile.input_identity}/pfp`, + body: body, + }); + return response.pfp_url; + } }, - onSuccess: (response) => { + onSuccess: (pfp_url) => { onProfileEdit({ profile: { ...profile, - profile: profile.profile - ? { ...profile.profile, pfp_url: response.pfp_url } - : null, + profile: profile.profile ? { ...profile.profile, pfp_url } : null, }, previousProfile: null, }); @@ -139,8 +186,7 @@ export default function UserPageHeaderEditPfp({
+ className={`sm:tw-max-w-3xl md:tw-max-w-2xl tw-relative tw-w-full tw-transform tw-rounded-xl tw-bg-iron-950 tw-text-left tw-shadow-xl tw-transition-all tw-duration-500 sm:tw-w-full tw-p-6 lg:tw-p-8`}>
{ + const elementsWithSrc = document.querySelectorAll("[src]"); + Array.from(elementsWithSrc).forEach((el) => { + const src = el.getAttribute("src")!; + const newSrc = useIpfsGatewayUrl(src); + el.setAttribute("src", newSrc); + }); + }; + + useEffect(() => { + updateImagesSrc(); + + const observer = new MutationObserver(() => { + updateImagesSrc(); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + }; + }, []); + return ( @@ -292,21 +322,22 @@ export default function App({ Component, ...rest }: AppPropsWithLayout) { )} - - - - - {getLayout()} - - - - - + + + + + + {getLayout()} + + + + + + {!hideFooter &&