diff --git a/web/.env.devnet-neo.public b/web/.env.devnet-neo.public index ccf47427b..f9068f7db 100644 --- a/web/.env.devnet-neo.public +++ b/web/.env.devnet-neo.public @@ -5,7 +5,7 @@ export REACT_APP_DRT_ARBSEPOLIA_SUBGRAPH=https://api.studio.thegraph.com/query/6 export REACT_APP_STATUS_URL=https://kleros-v2-devnet.betteruptime.com/badge export REACT_APP_GENESIS_BLOCK_ARBSEPOLIA=3084598 export REACT_APP_ARBITRATOR_TYPE=neo -export REACT_APP_ATLAS_URI=http://localhost:3000/graphql +export REACT_APP_ATLAS_URI=http://localhost:3000 export WALLETCONNECT_PROJECT_ID= export ALCHEMY_API_KEY= export NODE_OPTIONS='--max-old-space-size=7680' diff --git a/web/.env.devnet-university.public b/web/.env.devnet-university.public index 2ae062b0a..1e167ec4b 100644 --- a/web/.env.devnet-university.public +++ b/web/.env.devnet-university.public @@ -5,7 +5,7 @@ export REACT_APP_DRT_ARBSEPOLIA_SUBGRAPH=https://api.studio.thegraph.com/query/6 export REACT_APP_STATUS_URL=https://kleros-v2-devnet.betteruptime.com/badge export REACT_APP_GENESIS_BLOCK_ARBSEPOLIA=3084598 export REACT_APP_ARBITRATOR_TYPE=university -export REACT_APP_ATLAS_URI=http://localhost:3000/graphql +export REACT_APP_ATLAS_URI=http://localhost:3000 export WALLETCONNECT_PROJECT_ID= export ALCHEMY_API_KEY= export NODE_OPTIONS='--max-old-space-size=7680' diff --git a/web/.env.devnet.public b/web/.env.devnet.public index 943c2148d..ebb4831a6 100644 --- a/web/.env.devnet.public +++ b/web/.env.devnet.public @@ -4,7 +4,7 @@ export REACT_APP_CORE_SUBGRAPH=https://api.studio.thegraph.com/query/61738/klero export REACT_APP_DRT_ARBSEPOLIA_SUBGRAPH=https://api.studio.thegraph.com/query/61738/kleros-v2-drt-arbisep-devnet/version/latest export REACT_APP_STATUS_URL=https://kleros-v2-devnet.betteruptime.com/badge export REACT_APP_GENESIS_BLOCK_ARBSEPOLIA=3084598 -export REACT_APP_ATLAS_URI=http://localhost:3000/graphql +export REACT_APP_ATLAS_URI=http://localhost:3000 export WALLETCONNECT_PROJECT_ID= export ALCHEMY_API_KEY= export NODE_OPTIONS='--max-old-space-size=7680' diff --git a/web/.env.local.public b/web/.env.local.public index b71f31e93..b5a5cdd43 100644 --- a/web/.env.local.public +++ b/web/.env.local.public @@ -2,7 +2,7 @@ export REACT_APP_DEPLOYMENT=devnet export REACT_APP_CORE_SUBGRAPH=http://localhost:8000/subgraphs/name/kleros/kleros-v2-core-local export REACT_APP_DRT_ARBSEPOLIA_SUBGRAPH=https://api.thegraph.com/subgraphs/name/alcercu/templateregistrydevnet -export REACT_APP_ATLAS_URI=http://localhost:3000/graphql +export REACT_APP_ATLAS_URI=http://localhost:3000 export WALLETCONNECT_PROJECT_ID= export ALCHEMY_API_KEY= export NODE_OPTIONS='--max-old-space-size=7680' diff --git a/web/.env.mainnet-neo.public b/web/.env.mainnet-neo.public index a0eb52416..a32745c02 100644 --- a/web/.env.mainnet-neo.public +++ b/web/.env.mainnet-neo.public @@ -5,7 +5,7 @@ export REACT_APP_DRT_ARBMAINNET_SUBGRAPH=https://api.studio.thegraph.com/query/6 export REACT_APP_STATUS_URL=https://kleros-v2-devnet.betteruptime.com/badge export REACT_APP_GENESIS_BLOCK_ARBMAINNET=190274403 export REACT_APP_ARBITRATOR_TYPE=neo -export REACT_APP_ATLAS_URI=http://localhost:3000/graphql +export REACT_APP_ATLAS_URI=http://localhost:3000 export WALLETCONNECT_PROJECT_ID= export ALCHEMY_API_KEY= export NODE_OPTIONS='--max-old-space-size=7680' diff --git a/web/.env.testnet.public b/web/.env.testnet.public index ab5c07779..43c83fd02 100644 --- a/web/.env.testnet.public +++ b/web/.env.testnet.public @@ -3,7 +3,7 @@ export REACT_APP_DEPLOYMENT=testnet export REACT_APP_CORE_SUBGRAPH=https://api.studio.thegraph.com/query/61738/kleros-v2-core-testnet/version/latest export REACT_APP_DRT_ARBSEPOLIA_SUBGRAPH=https://api.studio.thegraph.com/query/61738/kleros-v2-drt-arbisep-devnet/version/latest export REACT_APP_STATUS_URL=https://kleros-v2.betteruptime.com/badge -export REACT_APP_ATLAS_URI=http://localhost:3000/graphql +export REACT_APP_ATLAS_URI=http://localhost:3000 export REACT_APP_GENESIS_BLOCK_ARBSEPOLIA=3842783 export WALLETCONNECT_PROJECT_ID= export ALCHEMY_API_KEY= diff --git a/web/netlify/functions/uploadToIPFS.ts b/web/netlify/functions/uploadToIPFS.ts deleted file mode 100644 index e59ccfcb7..000000000 --- a/web/netlify/functions/uploadToIPFS.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { File, FilebaseClient } from "@filebase/client"; -import middy from "@middy/core"; -import amqp, { Connection } from "amqplib"; -import busboy from "busboy"; - -import { authMiddleware } from "../middleware/authMiddleware"; - -const { FILEBASE_TOKEN, RABBITMQ_URL } = process.env; -const filebase = new FilebaseClient({ token: FILEBASE_TOKEN ?? "" }); - -type FormElement = - | { isFile: true; filename: string; mimeType: string; content: Buffer } - | { isFile: false; content: string }; -type FormData = { [key: string]: FormElement }; - -const emitRabbitMQLog = async (cid: string, operation: string) => { - let connection: Connection | undefined; - try { - connection = await amqp.connect(RABBITMQ_URL ?? ""); - const channel = await connection.createChannel(); - - await channel.assertExchange("ipfs", "topic"); - channel.publish("ipfs", operation, Buffer.from(cid)); - - //eslint-disable-next-line no-console - console.log(`Sent IPFS CID '${cid}' to exchange 'ipfs'`); - } catch (err) { - console.warn(err); - } finally { - if (typeof connection !== "undefined") await connection.close(); - } -}; - -const parseMultipart = ({ headers, body, isBase64Encoded }) => - new Promise((resolve, reject) => { - const fields: FormData = {}; - - const bb = busboy({ headers }); - - bb.on("file", (name, file, { filename, mimeType }) => - file.on("data", (content) => { - fields[name] = { isFile: true, filename, mimeType, content }; - }) - ) - .on("field", (name, value) => { - if (value) fields[name] = { isFile: false, content: value }; - }) - .on("close", () => resolve(fields)) - .on("error", (err) => reject(err)); - - bb.write(body, isBase64Encoded ? "base64" : "binary"); - bb.end(); - }); - -const pinToFilebase = async (data: FormData, operation: string): Promise> => { - const cids = new Array(); - for (const [_, dataElement] of Object.entries(data)) { - if (dataElement.isFile) { - const { filename, mimeType, content } = dataElement; - const path = `${filename}`; - const cid = await filebase.storeDirectory([new File([content], path, { type: mimeType })]); - await emitRabbitMQLog(cid, operation); - cids.push(`ipfs://${cid}/${path}`); - } - } - - return cids; -}; - -export const uploadToIPFS = async (event) => { - const { queryStringParameters } = event; - - if (!queryStringParameters?.operation) { - return { - statusCode: 400, - body: JSON.stringify({ message: "Invalid query parameters" }), - }; - } - - const { operation } = queryStringParameters; - - try { - const parsed = await parseMultipart(event); - const cids = await pinToFilebase(parsed, operation); - - return { - statusCode: 200, - body: JSON.stringify({ - message: "File has been stored successfully", - cids, - }), - }; - } catch (err: any) { - return { - statusCode: 500, - body: JSON.stringify({ message: err.message }), - }; - } -}; - -export const handler = middy(uploadToIPFS).use(authMiddleware()); diff --git a/web/netlify/middleware/authMiddleware.ts b/web/netlify/middleware/authMiddleware.ts deleted file mode 100644 index d54d7f9bd..000000000 --- a/web/netlify/middleware/authMiddleware.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as jwt from "jose"; - -export const authMiddleware = () => { - return { - before: async (request) => { - const { event } = request; - - const authToken = event?.headers?.["x-auth-token"]; - if (!authToken) { - return { - statusCode: 400, - body: JSON.stringify({ message: `Error : Missing x-auth-token in Header}` }), - }; - } - - try { - const secret = process.env.JWT_SECRET; - - if (!secret) { - throw new Error("Secret not set in environment"); - } - - const encodedSecret = new TextEncoder().encode(secret); - - const { payload } = await jwt.jwtVerify(authToken, encodedSecret); - - // add auth details to event - request.event.auth = payload; - } catch (err) { - return { - statusCode: 401, - body: JSON.stringify({ message: `Error : ${err?.message ?? "Not Authorised"}` }), - }; - } - }, - }; -}; diff --git a/web/src/context/AtlasProvider.tsx b/web/src/context/AtlasProvider.tsx index 9aa8f4b97..b34dbe3f2 100644 --- a/web/src/context/AtlasProvider.tsx +++ b/web/src/context/AtlasProvider.tsx @@ -14,11 +14,14 @@ import { fetchUser, updateEmail as updateEmailInAtlas, confirmEmail as confirmEmailInAtlas, + uploadToIpfs, type User, type AddUserData, type UpdateEmailData, type ConfirmEmailData, type ConfirmEmailResponse, + Roles, + Products, } from "utils/atlas"; import { isUndefined } from "src/utils"; @@ -29,11 +32,13 @@ interface IAtlasProvider { isAddingUser: boolean; isFetchingUser: boolean; isUpdatingUser: boolean; + isUploadingFile: boolean; user: User | undefined; userExists: boolean; authoriseUser: () => void; addUser: (userSettings: AddUserData) => Promise; updateEmail: (userSettings: UpdateEmailData) => Promise; + uploadFile: (file: File, role: Roles) => Promise; confirmEmail: (userSettings: ConfirmEmailData) => Promise< ConfirmEmailResponse & { isError: boolean; @@ -57,6 +62,7 @@ const AtlasProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) = const [isAddingUser, setIsAddingUser] = useState(false); const [isUpdatingUser, setIsUpdatingUser] = useState(false); const [isVerified, setIsVerified] = useState(false); + const [isUploadingFile, setIsUploadingFile] = useState(false); const { signMessageAsync } = useSignMessage(); const atlasGqlClient = useMemo(() => { @@ -65,7 +71,7 @@ const AtlasProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) = authorization: `Bearer ${authToken}`, } : undefined; - return new GraphQLClient(atlasUri, { headers }); + return new GraphQLClient(`${atlasUri}/graphql`, { headers }); }, [authToken]); /** @@ -131,7 +137,7 @@ const AtlasProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) = // this would change based on the fields we have and what defines a user to be existing const userExists = useMemo(() => { if (!user) return false; - return user.email ? true : false; + return !isUndefined(user.email); }, [user]); /** @@ -208,6 +214,35 @@ const AtlasProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) = [address, isVerified, setIsUpdatingUser, atlasGqlClient, refetchUser] ); + /** + * @description upload file to ipfs + * @param {File} file - file to be uploaded + * @param {Roles} role - role for which file is being uploaded + * @returns {Promise} A promise that resolves to the ipfs cid if file was uploaded successfully else + * null + */ + const uploadFile = useCallback( + async (file: File, role: Roles) => { + try { + if (!address || !isVerified || !atlasUri || !authToken) return null; + setIsUploadingFile(true); + + const hash = await uploadToIpfs( + { baseUrl: atlasUri, authToken }, + { file, name: file.name, role, product: Products.CourtV2 } + ); + return hash ? `/ipfs/${hash}` : null; + } catch (err: any) { + // eslint-disable-next-line + console.log("Upload File Error : ", err?.message); + return null; + } finally { + setIsUploadingFile(false); + } + }, + [address, isVerified, setIsUploadingFile, authToken] + ); + /** * @description confirms user email in atlas * @param {ConfirmEmailData} userSettings - object containing data to be sent @@ -244,6 +279,8 @@ const AtlasProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) = updateEmail, isUpdatingUser, userExists, + isUploadingFile, + uploadFile, confirmEmail, }), [ @@ -257,6 +294,8 @@ const AtlasProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) = updateEmail, isUpdatingUser, userExists, + isUploadingFile, + uploadFile, confirmEmail, ] )} diff --git a/web/src/pages/Cases/CaseDetails/Evidence/SubmitEvidenceModal.tsx b/web/src/pages/Cases/CaseDetails/Evidence/SubmitEvidenceModal.tsx index 24e74f74d..2b7caab8a 100644 --- a/web/src/pages/Cases/CaseDetails/Evidence/SubmitEvidenceModal.tsx +++ b/web/src/pages/Cases/CaseDetails/Evidence/SubmitEvidenceModal.tsx @@ -7,8 +7,9 @@ import { useWalletClient, usePublicClient, useConfig } from "wagmi"; import { Textarea, Button, FileUploader } from "@kleros/ui-components-library"; +import { useAtlasProvider } from "context/AtlasProvider"; import { simulateEvidenceModuleSubmitEvidence } from "hooks/contracts/generated"; -import { uploadFormDataToIPFS } from "utils/uploadFormDataToIPFS"; +import { Roles } from "utils/atlas"; import { wrapWithToast, OPTIONS as toastOptions } from "utils/wrapWithToast"; import EnsureAuth from "components/EnsureAuth"; @@ -61,23 +62,28 @@ const SubmitEvidenceModal: React.FC<{ const [isSending, setIsSending] = useState(false); const [message, setMessage] = useState(""); const [file, setFile] = useState(); + const { uploadFile } = useAtlasProvider(); const submitEvidence = useCallback(async () => { - setIsSending(true); - const evidenceJSON = await constructEvidence(message, file); - - const { request } = await simulateEvidenceModuleSubmitEvidence(wagmiConfig, { - args: [BigInt(evidenceGroup), JSON.stringify(evidenceJSON)], - }); - - if (!walletClient) return; - await wrapWithToast(async () => await walletClient.writeContract(request), publicClient) - .then(() => { - setMessage(""); - close(); - }) - .finally(() => setIsSending(false)); - }, [publicClient, wagmiConfig, walletClient, close, evidenceGroup, file, message, setIsSending]); + try { + setIsSending(true); + const evidenceJSON = await constructEvidence(uploadFile, message, file); + + const { request } = await simulateEvidenceModuleSubmitEvidence(wagmiConfig, { + args: [BigInt(evidenceGroup), JSON.stringify(evidenceJSON)], + }); + + if (!walletClient || !publicClient) return; + await wrapWithToast(async () => await walletClient.writeContract(request), publicClient) + .then(() => { + setMessage(""); + close(); + }) + .finally(() => setIsSending(false)); + } catch { + setIsSending(false); + } + }, [publicClient, wagmiConfig, walletClient, close, evidenceGroup, file, message, setIsSending, uploadFile]); return ( @@ -96,16 +102,16 @@ const SubmitEvidenceModal: React.FC<{ ); }; -const constructEvidence = async (msg: string, file?: File) => { - let fileURI: string | undefined = undefined; +const constructEvidence = async ( + uploadFile: (file: File, role: Roles) => Promise, + msg: string, + file?: File +) => { + let fileURI: string | null = null; if (file) { toast.info("Uploading to IPFS", toastOptions); - const fileFormData = new FormData(); - fileFormData.append("data", file, file.name); - fileURI = await uploadFormDataToIPFS(fileFormData).then(async (res) => { - const response = await res.json(); - return response["cids"][0]; - }); + fileURI = await uploadFile(file, Roles.Evidence); + if (!fileURI) throw new Error("Error uploading evidence file"); } return { name: "Evidence", description: msg, fileURI }; }; diff --git a/web/src/pages/Resolver/Policy/index.tsx b/web/src/pages/Resolver/Policy/index.tsx index 49b54bcd1..036317846 100644 --- a/web/src/pages/Resolver/Policy/index.tsx +++ b/web/src/pages/Resolver/Policy/index.tsx @@ -5,8 +5,9 @@ import { toast } from "react-toastify"; import { FileUploader } from "@kleros/ui-components-library"; +import { useAtlasProvider } from "context/AtlasProvider"; import { useNewDisputeContext } from "context/NewDisputeContext"; -import { uploadFormDataToIPFS } from "utils/uploadFormDataToIPFS"; +import { Roles } from "utils/atlas"; import { OPTIONS as toastOptions } from "utils/wrapWithToast"; import { landscapeStyle } from "styles/landscapeStyle"; @@ -51,19 +52,16 @@ const StyledFileUploader = styled(FileUploader)` const Policy: React.FC = () => { const { disputeData, setDisputeData, setIsPolicyUploading } = useNewDisputeContext(); + const { uploadFile } = useAtlasProvider(); const handleFileUpload = (file: File) => { setIsPolicyUploading(true); toast.info("Uploading Policy to IPFS", toastOptions); - const fileFormData = new FormData(); - fileFormData.append("data", file, file.name); - - uploadFormDataToIPFS(fileFormData, "policy") - .then(async (res) => { - const response = await res.json(); - const policyURI = response["cids"][0]; - setDisputeData({ ...disputeData, policyURI }); + uploadFile(file, Roles.Policy) + .then(async (cid) => { + if (!cid) return; + setDisputeData({ ...disputeData, policyURI: cid }); }) .catch((err) => console.log(err)) .finally(() => setIsPolicyUploading(false)); diff --git a/web/src/utils/atlas/index.ts b/web/src/utils/atlas/index.ts index 4e113034e..de03790bc 100644 --- a/web/src/utils/atlas/index.ts +++ b/web/src/utils/atlas/index.ts @@ -5,3 +5,4 @@ export * from "./addUser"; export * from "./fetchUser"; export * from "./updateEmail"; export * from "./confirmEmail"; +export * from "./uploadToIpfs"; diff --git a/web/src/utils/atlas/uploadToIpfs.ts b/web/src/utils/atlas/uploadToIpfs.ts new file mode 100644 index 000000000..dfbbf8211 --- /dev/null +++ b/web/src/utils/atlas/uploadToIpfs.ts @@ -0,0 +1,61 @@ +import { toast } from "react-toastify"; + +import { OPTIONS } from "utils/wrapWithToast"; + +export enum Products { + CourtV2 = "CourtV2", +} + +export enum Roles { + Photo = "photo", + Evidence = "evidence", + Policy = "policy", + Generic = "generic", +} + +export type IpfsUploadPayload = { + file: File; + name: string; + product: Products; + role: Roles; +}; + +type Config = { + baseUrl: string; + authToken: string; +}; + +export async function uploadToIpfs(config: Config, payload: IpfsUploadPayload): Promise { + const formData = new FormData(); + formData.append("file", payload.file, payload.name); + formData.append("name", payload.name); + formData.append("product", payload.product); + formData.append("role", payload.role); + + return toast.promise( + fetch(`${config.baseUrl}/ipfs/file`, { + method: "POST", + headers: { + authorization: `Bearer ${config.authToken}`, + }, + body: formData, + }).then(async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: "Error uploading to IPFS" })); + throw new Error(error.message); + } + + return await response.text(); + }), + { + pending: `Uploading to IPFS...`, + success: "Uploaded successfully!", + error: { + render({ data: error }) { + return `Upload failed: ${error?.message}`; + }, + }, + }, + OPTIONS + ); +}