diff --git a/.env.example b/.env.example index 3263b51..69de436 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,13 @@ NEXT_PUBLIC_SOLANA_RPC_URL=https://young-radial-orb.solana-mainnet.quiknode.pro/ NEXT_PUBLIC_SOLANA_WEBSOCKET_URL=wss://young-radial-orb.solana-mainnet.quiknode.pro/67612b364664616c29514e551bf5de38447ca3d4 NEXT_PUBLIC_USDC_MINT=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v -# Get keys from https://app.pinata.cloud/developers/api-keys +# Generate a key at https://app.pinata.cloud/developers/api-keys PINATA_JWT= + +# Get the gateway from https://app.pinata.cloud/gateway PINATA_GATEWAY= + +# Change to your website URL +# For development: SITE_URL=http://localhost:3000 +SITE_URL=https://memes.markto.market +NEXT_PUBLIC_ACCOUNT_HANDLE= diff --git a/README.md b/README.md index 526c803..9ea9eef 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,25 @@ This project is a Solana-based meme generator that allows users to connect their FAL_AI_KEY=your_fal_ai_key ``` -- Setup a project on then add the following to `.env.example`: - ```env - # Get keys from https://app.pinata.cloud/developers/api-keys - PINATA_JWT= - PINATA_GATEWAY= - ``` +- Setup a project on + + - Visit https://app.pinata.cloud/developers/api-keys and click the `New Key` button to create a new key + + - Choose the `Admin` scope and create the API key + + - Copy the JWT that is displayed + + - Add the following to `.env`: + + ```env + PINATA_JWT= + + # Get the gateway from https://app.pinata.cloud/gateway + PINATA_GATEWAY= + + # Add the account handle to be set in the tweet + NEXT_PUBLIC_ACCOUNT_HANDLE= + ``` - Run the development server: ```sh diff --git a/server.ts b/server.ts index 55da595..8d23e55 100644 --- a/server.ts +++ b/server.ts @@ -1,11 +1,15 @@ import { createServer } from 'http'; import { parse } from 'url'; import next from 'next'; +import { DataSource, EntityTarget } from 'typeorm'; // Reference: https://github.com/motdotla/dotenv?tab=readme-ov-file#how-do-i-use-dotenv-with-import import 'dotenv/config' import { QuotesService } from './quotes-service'; +import { initializeDataSource } from './src/data-source'; +import { Payment } from './src/entity/Payment'; +import { Tweet } from './src/entity/Tweet'; const port = parseInt(process.env.PORT || '3000', 10); const app = next({ dev: process.env.NODE_ENV !== 'production' }); @@ -16,16 +20,25 @@ const quotesService = new QuotesService(); declare global { namespace NodeJS { interface Global { - quotesService: typeof quotesService + quotesService: QuotesService + appDataSource: DataSource + entities: { [key: string]: EntityTarget} } } } // TODO: Look for a better way to use quotesService // Initialize global quotes service -(global as any).quotesService = quotesService +global.quotesService = quotesService + +global.entities = { + Payment, + Tweet +}; app.prepare().then(async() => { + global.appDataSource = await initializeDataSource(); + const server = createServer(async (req, res) => { const parsedUrl = parse(req.url!, true); diff --git a/src/app/api/flux/route.ts b/src/app/api/flux/route.ts index ff96d38..afd9193 100644 --- a/src/app/api/flux/route.ts +++ b/src/app/api/flux/route.ts @@ -3,7 +3,6 @@ import BN from 'bn.js'; import { fal } from "@fal-ai/client" import { FLUX_MODELS } from '../../../services/fluxService' -import { initializeDataSource } from '../../../data-source' import { verifyPayment, markSignatureAsUsed } from '../../../utils/verifyPayment'; import { uploadToPinata } from '../../../utils/uploadToPinata'; @@ -22,9 +21,9 @@ const IMAGE_HEIGHT: number = 1024 export async function POST(req: NextRequest): Promise { try { - await initializeDataSource(); - - const { prompt, modelId, transactionSignature } = await req.json() + const { prompt, modelId, transactionSignature } = await req.json(); + const host = req.headers.get("host"); // Get the hostname from request headers + const protocol = req.headers.get("x-forwarded-proto") || "http"; // Handle reverse proxies if (!prompt || !modelId) { return NextResponse.json( @@ -107,7 +106,10 @@ export async function POST(req: NextRequest): Promise { ) } - const publicUrl = pinataResult.imageUrl; + // Extract CID from the URL + const cid = pinataResult.imageUrl!.split("/ipfs/")[1]; + + const publicUrl = `${protocol}://${host}/api/image/${cid}`; return NextResponse.json({ imageUrl: publicUrl }) } catch (error) { diff --git a/src/app/api/image/[cid]/route.ts b/src/app/api/image/[cid]/route.ts new file mode 100644 index 0000000..bc481a6 --- /dev/null +++ b/src/app/api/image/[cid]/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest, { params }: { params: { cid?: string } }) { + const { cid } = params; + + if (!cid) { + return NextResponse.json({ error: 'CID is required' }, { status: 400 }); + } + + const pinataUrl = `https://${process.env.PINATA_GATEWAY}/ipfs/${cid}`; + + try { + const response = await fetch(pinataUrl); + + if (!response.ok) { + return NextResponse.json({ error: 'Failed to fetch from Pinata' }, { status: response.status }); + } + + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const buffer = await response.arrayBuffer(); + + return new NextResponse(buffer, { + status: 200, + headers: { 'Content-Type': contentType }, + }); + } catch (error) { + console.error('Error fetching from Pinata:', error); + return NextResponse.json({ error: 'Server error' }, { status: 500 }); + } +} diff --git a/src/app/api/tweet/route.ts b/src/app/api/tweet/route.ts new file mode 100644 index 0000000..f514ff9 --- /dev/null +++ b/src/app/api/tweet/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { saveTweet, verifySignatureInTweet } from '../../../utils/verifyTweet'; + +export async function POST(req: NextRequest): Promise { + try { + const { tweetUrl } = await req.json(); + + const url = `https://publish.twitter.com/oembed?url=${tweetUrl}&maxwidth=600`; + + const response = await fetch(url); + const data = await response.json(); + + const { handle, txSignature, memeUrl } = extractData(data.html); + + if (!handle || !txSignature) { + return NextResponse.json( + { error: 'Could not extract tweet data' }, + { status: 500 } + ) + } + + const isSigVerified = await verifySignatureInTweet(txSignature); + const isHandleCorrect = handle === process.env.NEXT_PUBLIC_ACCOUNT_HANDLE; + + // TODO: Verify dynamic page URL in tweet + const isVerified = isSigVerified && isHandleCorrect; + if (!isVerified) { + throw new Error('Tweet is not valid'); + } + + const { isFourthUser } = await saveTweet({ transactionSignature: txSignature, url: memeUrl }); + if (isFourthUser) { + createTokenLockForRecipient(); + } + + return NextResponse.json({ success: isVerified, message: 'Tweet verified' }) + } catch (error) { + console.error('Error while verifying tweet:', error) + return NextResponse.json( + { error: 'Failed to verify tweet' }, + { status: 500 } + ) + } +} + +const extractData = (tweet: string | object) => { + const tweetText = typeof tweet === 'string' ? tweet : JSON.stringify(tweet); + + const decodedTweet = tweetText.replace(/'/g, "'").replace(/"/g, '"'); + + const urlMatch = decodedTweet.match(//); + const txSignatureMatch = decodedTweet.match(/TX Hash: '([^']+)'/); + const handleMatch = decodedTweet.match(/@([A-Za-z0-9_]+)/); + + return { + memeUrl: urlMatch ? urlMatch[1] : null, + txSignature: txSignatureMatch ? txSignatureMatch[1].trim() : null, + handle: handleMatch ? handleMatch[1] : null, + }; +}; + +// TODO: Implement function to create lock for a recipient +const createTokenLockForRecipient = () => { + console.log('Lock created'); +} diff --git a/src/app/memes/[id]/page.tsx b/src/app/memes/[id]/page.tsx new file mode 100644 index 0000000..2acb110 --- /dev/null +++ b/src/app/memes/[id]/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata, ResolvingMetadata } from 'next'; + +interface Props { + params: { id: string }; +} + +function getMeme(id: string): string { + const memeUrl = `${process.env.SITE_URL}/api/image/${id}` + return memeUrl; +} + +export async function generateMetadata( + { params }: Props, + parent: ResolvingMetadata +): Promise { + const baseUrl = process.env.SITE_URL!; + const meme = getMeme(params.id); + + return { + metadataBase: new URL(baseUrl), + openGraph: { + type: 'website', + title: 'Generated Meme', + url: `${baseUrl}/memes/${params.id}`, + images: [{ url: meme }], + }, + twitter: { + title: 'Generated Meme', + images: [{ url: meme }], + }, + }; +} + +export default function MemePage({ params }: Props) { + const meme = getMeme(params.id); + + return ( +
+
+
+ Generated meme +
+
+
+ ); +} diff --git a/src/components/AIServiceCard.tsx b/src/components/AIServiceCard.tsx index 12ef008..f9e96e7 100644 --- a/src/components/AIServiceCard.tsx +++ b/src/components/AIServiceCard.tsx @@ -3,12 +3,13 @@ import React, { useState } from 'react' import BN from 'bn.js'; import Big from 'big.js'; +import dynamic from 'next/dynamic' interface AIServiceCardProps { title: string description: string isWalletConnected: boolean - onGenerate: (prompt: string) => Promise<{ imageUrl?: string, error?: string }> + onGenerate: (prompt: string) => Promise<{ imageUrl?: string, transactionSignature?: string, error?: string }> priceMTM: BN } @@ -16,6 +17,7 @@ interface GenerationState { loading: boolean processing: boolean imageUrl: string | null + transactionSignature: string | null error: string | null } @@ -38,6 +40,7 @@ const AIServiceCard: React.FC = ({ loading: false, processing: false, imageUrl: null, + transactionSignature: null, error: null, }) @@ -67,11 +70,12 @@ const AIServiceCard: React.FC = ({ return } - if (result.imageUrl) { + if (result.imageUrl && result.transactionSignature) { setGenerationState({ loading: false, processing: false, imageUrl: result.imageUrl, + transactionSignature: result.transactionSignature, error: null, }) } else { @@ -86,6 +90,16 @@ const AIServiceCard: React.FC = ({ } } + const generateTwitterShareUrl = (imageUrl: string, transactionSignature: string): string => { + const baseUrl = window.location.href; + const cid = imageUrl.split("/image/")[1]; + const memeUrl = `${baseUrl}memes/${cid}`; + + const tweetText = `Check out this meme that I generated! \n TX Hash: '${transactionSignature}' \n @${process.env.NEXT_PUBLIC_ACCOUNT_HANDLE} \n`; + + return `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(memeUrl)}`; + }; + return (
@@ -143,4 +167,6 @@ const AIServiceCard: React.FC = ({ ) } -export default AIServiceCard +export default dynamic(() => Promise.resolve(AIServiceCard), { + ssr: false +}) diff --git a/src/components/TweetForm.tsx b/src/components/TweetForm.tsx new file mode 100644 index 0000000..f020319 --- /dev/null +++ b/src/components/TweetForm.tsx @@ -0,0 +1,61 @@ +'use client' + +import React, { useState } from 'react' + +const TweetUrlForm: React.FC = () => { + const [inputText, setInputText] = useState('') + + const handleVerify = async (): Promise => { + try { + const response = await fetch('/api/tweet', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tweetUrl: inputText }), + }) + + if (!response.ok) { + throw new Error(`Failed to verify tweet: ${response.statusText}`); + } + + } catch (error) { + console.error('Failed to fetch price:', error); + } + } + + return ( +
+
+
+ +
+ +
+