forked from mito-systems/sol-mem-gen
Add feature to share generated memes on twitter and verify tweet #10
@ -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_SOLANA_WEBSOCKET_URL=wss://young-radial-orb.solana-mainnet.quiknode.pro/67612b364664616c29514e551bf5de38447ca3d4
|
||||||
NEXT_PUBLIC_USDC_MINT=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
|
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=
|
PINATA_JWT=
|
||||||
|
|
||||||
|
# Get the gateway from https://app.pinata.cloud/gateway
|
||||||
PINATA_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=
|
||||||
|
17
README.md
17
README.md
@ -32,11 +32,24 @@ This project is a Solana-based meme generator that allows users to connect their
|
|||||||
FAL_AI_KEY=your_fal_ai_key
|
FAL_AI_KEY=your_fal_ai_key
|
||||||
```
|
```
|
||||||
|
|
||||||
- Setup a project on <https://app.pinata.cloud> then add the following to `.env.example`:
|
- 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
|
```env
|
||||||
# Get keys from https://app.pinata.cloud/developers/api-keys
|
|
||||||
PINATA_JWT=
|
PINATA_JWT=
|
||||||
|
|
||||||
|
# Get the gateway from https://app.pinata.cloud/gateway
|
||||||
PINATA_GATEWAY=
|
PINATA_GATEWAY=
|
||||||
|
|
||||||
|
# Add the account handle to be set in the tweet
|
||||||
|
NEXT_PUBLIC_ACCOUNT_HANDLE=
|
||||||
```
|
```
|
||||||
|
|
||||||
- Run the development server:
|
- Run the development server:
|
||||||
|
17
server.ts
17
server.ts
@ -1,11 +1,15 @@
|
|||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import { parse } from 'url';
|
import { parse } from 'url';
|
||||||
import next from 'next';
|
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
|
// Reference: https://github.com/motdotla/dotenv?tab=readme-ov-file#how-do-i-use-dotenv-with-import
|
||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
|
|
||||||
import { QuotesService } from './quotes-service';
|
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 port = parseInt(process.env.PORT || '3000', 10);
|
||||||
const app = next({ dev: process.env.NODE_ENV !== 'production' });
|
const app = next({ dev: process.env.NODE_ENV !== 'production' });
|
||||||
@ -16,16 +20,25 @@ const quotesService = new QuotesService();
|
|||||||
declare global {
|
declare global {
|
||||||
namespace NodeJS {
|
namespace NodeJS {
|
||||||
interface Global {
|
interface Global {
|
||||||
quotesService: typeof quotesService
|
quotesService: QuotesService
|
||||||
|
appDataSource: DataSource
|
||||||
|
entities: { [key: string]: EntityTarget<any>}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Look for a better way to use quotesService
|
// TODO: Look for a better way to use quotesService
|
||||||
// Initialize global quotes service
|
// Initialize global quotes service
|
||||||
(global as any).quotesService = quotesService
|
global.quotesService = quotesService
|
||||||
|
|
||||||
|
global.entities = {
|
||||||
|
Payment,
|
||||||
|
Tweet
|
||||||
|
};
|
||||||
|
|
||||||
app.prepare().then(async() => {
|
app.prepare().then(async() => {
|
||||||
|
global.appDataSource = await initializeDataSource();
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
const parsedUrl = parse(req.url!, true);
|
const parsedUrl = parse(req.url!, true);
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@ import BN from 'bn.js';
|
|||||||
|
|
||||||
import { fal } from "@fal-ai/client"
|
import { fal } from "@fal-ai/client"
|
||||||
import { FLUX_MODELS } from '../../../services/fluxService'
|
import { FLUX_MODELS } from '../../../services/fluxService'
|
||||||
import { initializeDataSource } from '../../../data-source'
|
|
||||||
import { verifyPayment, markSignatureAsUsed } from '../../../utils/verifyPayment';
|
import { verifyPayment, markSignatureAsUsed } from '../../../utils/verifyPayment';
|
||||||
import { uploadToPinata } from '../../../utils/uploadToPinata';
|
import { uploadToPinata } from '../../../utils/uploadToPinata';
|
||||||
|
|
||||||
@ -22,9 +21,9 @@ const IMAGE_HEIGHT: number = 1024
|
|||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
try {
|
try {
|
||||||
await initializeDataSource();
|
const { prompt, modelId, transactionSignature } = await req.json();
|
||||||
|
const host = req.headers.get("host"); // Get the hostname from request headers
|
||||||
const { prompt, modelId, transactionSignature } = await req.json()
|
const protocol = req.headers.get("x-forwarded-proto") || "http"; // Handle reverse proxies
|
||||||
|
|
||||||
if (!prompt || !modelId) {
|
if (!prompt || !modelId) {
|
||||||
return NextResponse.json(
|
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 })
|
return NextResponse.json({ imageUrl: publicUrl })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
30
src/app/api/image/[cid]/route.ts
Normal file
30
src/app/api/image/[cid]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
66
src/app/api/tweet/route.ts
Normal file
66
src/app/api/tweet/route.ts
Normal 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(/'/g, "'").replace(/"/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');
|
||||||
|
}
|
50
src/app/memes/[id]/page.tsx
Normal file
50
src/app/memes/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -3,12 +3,13 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import BN from 'bn.js';
|
import BN from 'bn.js';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
interface AIServiceCardProps {
|
interface AIServiceCardProps {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
isWalletConnected: boolean
|
isWalletConnected: boolean
|
||||||
onGenerate: (prompt: string) => Promise<{ imageUrl?: string, error?: string }>
|
onGenerate: (prompt: string) => Promise<{ imageUrl?: string, transactionSignature?: string, error?: string }>
|
||||||
priceMTM: BN
|
priceMTM: BN
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,6 +17,7 @@ interface GenerationState {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
processing: boolean
|
processing: boolean
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
|
transactionSignature: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,6 +40,7 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
processing: false,
|
processing: false,
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
|
transactionSignature: null,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -67,11 +70,12 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.imageUrl) {
|
if (result.imageUrl && result.transactionSignature) {
|
||||||
setGenerationState({
|
setGenerationState({
|
||||||
loading: false,
|
loading: false,
|
||||||
processing: false,
|
processing: false,
|
||||||
imageUrl: result.imageUrl,
|
imageUrl: result.imageUrl,
|
||||||
|
transactionSignature: result.transactionSignature,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
} else {
|
} 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 (
|
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="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="p-6">
|
||||||
@ -129,13 +143,23 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{generationState.imageUrl && (
|
{generationState.imageUrl && generationState.transactionSignature && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<img
|
<img
|
||||||
src={generationState.imageUrl}
|
src={generationState.imageUrl}
|
||||||
alt="Generated content"
|
alt="Generated content"
|
||||||
className="w-full h-auto rounded-xl shadow-2xl"
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -143,4 +167,6 @@ const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AIServiceCard
|
export default dynamic(() => Promise.resolve(AIServiceCard), {
|
||||||
|
ssr: false
|
||||||
|
})
|
||||||
|
61
src/components/TweetForm.tsx
Normal file
61
src/components/TweetForm.tsx
Normal 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
|
@ -1,23 +1,21 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { Payment } from './entity/Payment';
|
export async function initializeDataSource() {
|
||||||
|
try {
|
||||||
|
console.log('Initializing Data Source');
|
||||||
|
|
||||||
export const AppDataSource = new DataSource({
|
const appDataSource = new DataSource({
|
||||||
type: 'sqlite',
|
type: 'sqlite',
|
||||||
database: './database.sqlite',
|
database: './database.sqlite',
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
logging: false,
|
logging: false,
|
||||||
entities: [Payment],
|
entities: [global.entities.Payment, global.entities.Tweet],
|
||||||
migrations: [],
|
migrations: [],
|
||||||
subscribers: [],
|
subscribers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function initializeDataSource() {
|
await appDataSource.initialize();
|
||||||
try {
|
return appDataSource;
|
||||||
if (!AppDataSource.isInitialized){
|
|
||||||
await AppDataSource.initialize();
|
|
||||||
console.log('Data Source has been initialized!');
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error during Data Source initialization:', err);
|
console.error('Error during Data Source initialization:', err);
|
||||||
throw err;
|
throw err;
|
||||||
|
13
src/entity/Tweet.ts
Normal file
13
src/entity/Tweet.ts
Normal 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;
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
export interface FluxGenerationResult {
|
export interface FluxGenerationResult {
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
error?: string
|
error?: string
|
||||||
|
transactionSignature?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FluxModelConfig {
|
export interface FluxModelConfig {
|
||||||
@ -58,7 +59,7 @@ export async function generateWithFlux(
|
|||||||
console.log('Raw Flux response:', data)
|
console.log('Raw Flux response:', data)
|
||||||
|
|
||||||
if (data.imageUrl) {
|
if (data.imageUrl) {
|
||||||
return { imageUrl: data.imageUrl }
|
return { imageUrl: data.imageUrl, transactionSignature }
|
||||||
} else {
|
} else {
|
||||||
console.error('Unexpected response structure:', data)
|
console.error('Unexpected response structure:', data)
|
||||||
throw new Error('Invalid response format from Flux API')
|
throw new Error('Invalid response format from Flux API')
|
||||||
|
@ -4,7 +4,6 @@ import BN from 'bn.js';
|
|||||||
import { Connection } from '@solana/web3.js';
|
import { Connection } from '@solana/web3.js';
|
||||||
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
|
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
|
||||||
|
|
||||||
import { AppDataSource } from '../data-source';
|
|
||||||
import { Payment } from '../entity/Payment';
|
import { Payment } from '../entity/Payment';
|
||||||
|
|
||||||
assert(process.env.NEXT_PUBLIC_SOLANA_RPC_URL, 'SOLANA_RPC_URL is required');
|
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> {
|
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 });
|
const payment = await paymentRepository.findOneBy({ transactionSignature });
|
||||||
if (payment) {
|
if (payment) {
|
||||||
return true;
|
return true;
|
||||||
@ -31,8 +30,8 @@ export async function isSignatureUsed(transactionSignature: string): Promise<boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function markSignatureAsUsed(transactionSignature: string): Promise<void> {
|
export async function markSignatureAsUsed(transactionSignature: string): Promise<void> {
|
||||||
await AppDataSource.transaction(async (transactionalEntityManager) => {
|
await global.appDataSource.transaction(async (transactionalEntityManager) => {
|
||||||
const paymentRepository = transactionalEntityManager.getRepository(Payment);
|
const paymentRepository = transactionalEntityManager.getRepository(global.entities.Payment);
|
||||||
|
|
||||||
// Check if the payment with the given signature already exists
|
// Check if the payment with the given signature already exists
|
||||||
const exists = await paymentRepository.exists({ where: { transactionSignature } });
|
const exists = await paymentRepository.exists({ where: { transactionSignature } });
|
||||||
|
31
src/utils/verifyTweet.ts
Normal file
31
src/utils/verifyTweet.ts
Normal 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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user