Add feature to share generated memes on twitter and verify tweet #10

Merged
nabarun merged 16 commits from ag-dynamic-page into main 2025-02-06 05:45:03 +00:00
14 changed files with 349 additions and 39 deletions

View File

@ -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=

View File

@ -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 <https://app.pinata.cloud> 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 <https://app.pinata.cloud>
- 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

View File

@ -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<any>}
}
}
}
// 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);

View File

@ -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<NextResponse> {
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<NextResponse> {
)
}
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) {

View File

@ -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 });
}
}

View File

@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { saveTweet, verifySignatureInTweet } from '../../../utils/verifyTweet';
export async function POST(req: NextRequest): Promise<NextResponse> {
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(/&#39;/g, "'").replace(/&quot;/g, '"');
const urlMatch = decodedTweet.match(/<a href="(https:\/\/t.co\/[^"]+)">/);
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');
}

View File

@ -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<Metadata> {
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 (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="relative flex flex-col items-center">
<div className="relative w-full max-w-2xl aspect-square">
<img
src={meme}
alt='Generated meme'
className="object-contain rounded-lg w-full h-full"
/>
</div>
</div>
</main>
);
}

View File

@ -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<AIServiceCardProps> = ({
loading: false,
processing: false,
imageUrl: null,
transactionSignature: null,
error: null,
})
@ -67,11 +70,12 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
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<AIServiceCardProps> = ({
}
}
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 (
<div className="w-full bg-gray-800/50 backdrop-blur-lg rounded-2xl shadow-xl border border-gray-700/50 mb-8">
<div className="p-6">
@ -129,13 +143,23 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
</div>
)}
{generationState.imageUrl && (
{generationState.imageUrl && generationState.transactionSignature && (
<div className="mt-4">
<img
src={generationState.imageUrl}
alt="Generated content"
className="w-full h-auto rounded-xl shadow-2xl"
/>
<div className="mt-4 text-center">
<a
href={generateTwitterShareUrl(generationState.imageUrl, generationState.transactionSignature)}
target="_blank"
rel="noopener noreferrer"
className="inline-block w-full bg-blue-500 hover:bg-blue-600 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-blue-500/25"
>
Share on Twitter
</a>
</div>
</div>
)}
</div>
@ -143,4 +167,6 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
)
}
export default AIServiceCard
export default dynamic(() => Promise.resolve(AIServiceCard), {
ssr: false
})

View File

@ -0,0 +1,61 @@
'use client'
import React, { useState } from 'react'
const TweetUrlForm: React.FC = () => {
const [inputText, setInputText] = useState<string>('')
const handleVerify = async (): Promise<void> => {
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 (
<div className="w-full bg-gray-800/50 backdrop-blur-lg rounded-2xl shadow-xl border border-gray-700/50 mb-8">
<div className="p-6">
<div className="mb-4">
</div>
<div className="space-y-4">
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Enter the tweet URL here..."
className="w-full bg-gray-900/50 text-gray-100 border border-gray-700 rounded-xl p-4
placeholder-gray-500 focus:border-green-500 focus:ring-2 focus:ring-green-500/20
focus:outline-none min-h-[120px] transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed"
rows={4}
/>
<button
onClick={handleVerify}
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600
hover:to-emerald-600 text-white font-semibold py-4 px-6 rounded-xl
transition-all duration-200 shadow-lg hover:shadow-green-500/25
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none"
>
Verify
</button>
</div>
</div>
</div>
)
}
export default TweetUrlForm

View File

@ -1,23 +1,21 @@
import { DataSource } from 'typeorm';
import { Payment } from './entity/Payment';
export const AppDataSource = new DataSource({
type: 'sqlite',
database: './database.sqlite',
synchronize: true,
logging: false,
entities: [Payment],
migrations: [],
subscribers: [],
});
export async function initializeDataSource() {
try {
if (!AppDataSource.isInitialized){
await AppDataSource.initialize();
console.log('Data Source has been initialized!');
};
console.log('Initializing Data Source');
const appDataSource = new DataSource({
type: 'sqlite',
database: './database.sqlite',
synchronize: true,
logging: false,
entities: [global.entities.Payment, global.entities.Tweet],
migrations: [],
subscribers: [],
});
await appDataSource.initialize();
return appDataSource;
} catch (err) {
console.error('Error during Data Source initialization:', err);
throw err;

13
src/entity/Tweet.ts Normal file
View File

@ -0,0 +1,13 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class Tweet {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: 'text', unique: true, nullable: true })
url!: string | null;
@Column({ unique: true })
transactionSignature!: string;
}

View File

@ -1,6 +1,7 @@
export interface FluxGenerationResult {
imageUrl?: string
error?: string
transactionSignature?: string
}
export interface FluxModelConfig {
@ -58,7 +59,7 @@ export async function generateWithFlux(
console.log('Raw Flux response:', data)
if (data.imageUrl) {
return { imageUrl: data.imageUrl }
return { imageUrl: data.imageUrl, transactionSignature }
} else {
console.error('Unexpected response structure:', data)
throw new Error('Invalid response format from Flux API')

View File

@ -4,7 +4,6 @@ import BN from 'bn.js';
import { Connection } from '@solana/web3.js';
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
import { AppDataSource } from '../data-source';
import { Payment } from '../entity/Payment';
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
@ -22,7 +21,7 @@ const connection = new Connection(
);
export async function isSignatureUsed(transactionSignature: string): Promise<boolean> {
const paymentRepository = AppDataSource.getRepository(Payment);
const paymentRepository = global.appDataSource.getRepository(global.entities.Payment);
const payment = await paymentRepository.findOneBy({ transactionSignature });
if (payment) {
return true;
@ -31,8 +30,8 @@ export async function isSignatureUsed(transactionSignature: string): Promise<boo
}
export async function markSignatureAsUsed(transactionSignature: string): Promise<void> {
await AppDataSource.transaction(async (transactionalEntityManager) => {
const paymentRepository = transactionalEntityManager.getRepository(Payment);
await global.appDataSource.transaction(async (transactionalEntityManager) => {
const paymentRepository = transactionalEntityManager.getRepository(global.entities.Payment);
// Check if the payment with the given signature already exists
const exists = await paymentRepository.exists({ where: { transactionSignature } });

31
src/utils/verifyTweet.ts Normal file
View File

@ -0,0 +1,31 @@
import { Tweet } from '../entity/Tweet';
export async function verifySignatureInTweet(transactionSignature: string): Promise<boolean> {
const paymentRepository = global.appDataSource.getRepository(global.entities.Payment);
const payment = await paymentRepository.findOneBy({ transactionSignature });
if (!payment) {
return false;
}
const tweetRepository = global.appDataSource.getRepository(global.entities.Tweet);
const tweet = await tweetRepository.findOneBy({ transactionSignature });
if (tweet) {
return false;
}
return true;
}
export async function saveTweet(data: Partial<Tweet>): Promise<{ isFourthUser: boolean }> {
return await global.appDataSource.transaction(async (transactionalEntityManager) => {
const tweetRepository = transactionalEntityManager.getRepository(global.entities.Tweet);
const tweet = await tweetRepository.save(data);
return {
isFourthUser: tweet.id % 4 === 0
};
});
}