diff --git a/.env.example b/.env.example index b6405b2b..c39f8752 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,3 @@ -# Storage for metadata -# Create a Blob database and get token here: https://vercel.com/dashboard/stores?type=blob -BLOB_READ_WRITE_TOKEN= - # --------------------- # NETWORK CONFIGURATION # --------------------- @@ -84,3 +80,8 @@ NEXT_PUBLIC_TALLY_URL=https://upblxu2duoxmkobt.public.blob.vercel-storage.com NEXT_PUBLIC_POLL_MODE="non-qv" NEXT_PUBLIC_ROUND_LOGO="round-logo.png" + +NEXT_PUBLIC_IPFS_URL="" +NEXT_PUBLIC_IPFS_FETCHING_URL="" +IPFS_API_KEY="" +IPFS_SECRET="" \ No newline at end of file diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4c3fe025..c2d357dc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,6 +33,10 @@ env: NEXT_PUBLIC_VOTING_END_DATE: ${{ vars.NEXT_PUBLIC_VOTING_END_DATE }} NEXT_PUBLIC_RESULTS_DATE: ${{ vars.NEXT_PUBLIC_RESULTS_DATE }} NEXT_PUBLIC_POLL_MODE: ${{ vars.NEXT_PUBLIC_POLL_MODE }} + NEXT_PUBLIC_IPFS_URL: ${{ vars.NEXT_PUBLIC_IPFS_URL }} + NEXT_PUBLIC_IPFS_FETCHING_URL: ${{ vars.NEXT_PUBLIC_IPFS_FETCHING_URL }} + IPFS_API_KEY: ${{ secrets.IPFS_API_KEY }} + IPFS_SECRET: ${{ secrets.IPFS_SECRET }} TEST_MNEMONIC: ${{ secrets.TEST_MNEMONIC }} WALLET_PRIVATE_KEY: "" diff --git a/src/config.ts b/src/config.ts index 5c72f29a..64bc8009 100644 --- a/src/config.ts +++ b/src/config.ts @@ -67,6 +67,13 @@ export const config = { roundLogo: process.env.NEXT_PUBLIC_ROUND_LOGO, }; +export const ipfs = { + url: process.env.NEXT_PUBLIC_IPFS_URL ?? "", + apiKey: process.env.IPFS_API_KEY ?? "", + secret: process.env.IPFS_SECRET ?? "", + fetchingUrl: process.env.NEXT_PUBLIC_IPFS_FETCHING_URL ?? "", +}; + export const theme = { colorMode: "light", }; diff --git a/src/env.js b/src/env.js index 1c098597..93341bb0 100644 --- a/src/env.js +++ b/src/env.js @@ -69,6 +69,9 @@ module.exports = createEnv({ NEXT_PUBLIC_POLL_MODE: z.enum(["qv", "non-qv"]).default("non-qv"), NEXT_PUBLIC_ROUND_LOGO: z.string().optional(), + + NEXT_PUBLIC_IPFS_URL: z.string().url(), + NEXT_PUBLIC_IPFS_FETCHING_URL: z.string().url(), }, /** @@ -108,6 +111,9 @@ module.exports = createEnv({ NEXT_PUBLIC_POLL_MODE: process.env.NEXT_PUBLIC_POLL_MODE, NEXT_PUBLIC_ROUND_LOGO: process.env.NEXT_PUBLIC_ROUND_LOGO, + + NEXT_PUBLIC_IPFS_URL: process.env.NEXT_PUBLIC_IPFS_URL, + NEXT_PUBLIC_IPFS_FETCHING_URL: process.env.NEXT_PUBLIC_IPFS_FETCHING_URL, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 0c122010..f0d8692d 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -8,6 +8,13 @@ export function useMetadata(metadataPtr?: string): UseTRPCQueryResult | File> { return useMutation({ mutationFn: async (data: Record | File) => { @@ -30,7 +37,9 @@ export function useUploadMetadata(): UseMutationResult<{ url: string }, DefaultE if (!r.ok) { throw new Error("Network error"); } - return (await r.json()) as { url: string }; + const res = (await r.json()) as unknown as UploadResponse; + const hash = res.Hash ?? res.IpfsHash; + return { url: hash! }; }); }, }); diff --git a/src/pages/api/blob.ts b/src/pages/api/blob.ts index db0c6017..8cf2358a 100644 --- a/src/pages/api/blob.ts +++ b/src/pages/api/blob.ts @@ -1,13 +1,41 @@ -import { put } from "@vercel/blob"; +import { IncomingMessage } from "http"; + +import { ipfs } from "~/config"; import type { NextApiResponse, NextApiRequest, PageConfig } from "next"; -export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { - const blob = await put(req.query.filename as string, req, { - access: "public", +function bufferize(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + req.on("error", reject); }); +} + +export default async function blob(req: NextApiRequest, res: NextApiResponse): Promise { + const buffer = await bufferize(req); + const formData = new FormData(); + formData.append("file", new Blob([buffer]), req.query.filename as string); + + const response = await fetch(`${ipfs.url}/api/v0/add`, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${ipfs.apiKey}:${ipfs.secret}`).toString("base64")}`, + }, + body: formData, + }); + + if (!response.ok) { + res.status(response.status).json({ error: "Failed to upload to IPFS" }); + return; + } + + const data: unknown = await response.json(); - res.status(200).json(blob); + res.status(200).json(data); } export const config: PageConfig = { diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts index 46ac6b32..626bb716 100644 --- a/src/utils/fetch.ts +++ b/src/utils/fetch.ts @@ -6,17 +6,20 @@ export interface ICreateCachedFetchArgs { export type TCachedFetch = ( url: string, - opts?: { method: "POST" | "GET"; body?: string }, + opts?: { method: "POST" | "GET"; body?: string; headers?: Record }, ) => Promise<{ data: T; error: Error }>; export function createCachedFetch({ ttl = 1000 * 60 }: ICreateCachedFetchArgs): TCachedFetch { const cachedFetch = NodeFetchCache.create({ cache: new MemoryCache({ ttl }) }); - return async (url: string, opts?: { method: "POST" | "GET"; body?: string }): Promise<{ data: T; error: Error }> => + return async ( + url: string, + opts?: { method: "POST" | "GET"; body?: string; headers?: Record }, + ): Promise<{ data: T; error: Error }> => cachedFetch(url, { method: opts?.method ?? "GET", body: opts?.body, - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...opts?.headers }, }).then(async (r) => { if (!r.ok) { await r.ejectFromCache(); diff --git a/src/utils/fetchMetadata.ts b/src/utils/fetchMetadata.ts index 7954f929..22edfc8a 100644 --- a/src/utils/fetchMetadata.ts +++ b/src/utils/fetchMetadata.ts @@ -1,3 +1,5 @@ +import { ipfs } from "~/config"; + import { createCachedFetch } from "./fetch"; // ipfs data never changes @@ -5,10 +7,13 @@ const ttl = 2147483647; const cachedFetch = createCachedFetch({ ttl }); export async function fetchMetadata(url: string): Promise<{ data: T; error: Error }> { - const ipfsGateway = process.env.NEXT_PUBLIC_IPFS_GATEWAY ?? "https://dweb.link/ipfs/"; - if (!url.startsWith("http")) { - return cachedFetch(`${ipfsGateway}${url}`); + return cachedFetch(`${ipfs.fetchingUrl}${url}`, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${ipfs.apiKey}:${ipfs.secret}`).toString("base64")}`, + }, + }); } return cachedFetch(url);