From f497697166aacd587eda7638b23f3b1b00e034b4 Mon Sep 17 00:00:00 2001 From: codingsh Date: Fri, 4 Oct 2024 09:20:10 +0400 Subject: [PATCH] feat(openframes): enable support openframes - enable support lens frames - enable support XMTP - add validators - add news endpoints --- .../[frameId]/[handler]/[[...query]]/route.ts | 119 +++---- app/(app)/f/[frameId]/interactions/route.ts | 2 +- app/(app)/f/[frameId]/show/page.tsx | 2 +- app/(app)/loading.tsx | 1 - .../of/[frameId]/[handler]/[[query]]/route.ts | 174 ++++++++++ .../[frameId]/[handler]/[[...query]]/route.ts | 88 +++-- app/(app)/page.tsx | 12 +- app/(app)/templates/page.tsx | 5 +- app/layout.tsx | 1 - app/loading.tsx | 1 - app/manifest.ts | 2 +- app/robots.ts | 2 +- lib/farcaster.d.ts | 45 ++- lib/frame.ts | 324 ++++-------------- lib/lens-frames.ts | 158 +++++++++ lib/openframes.ts | 45 +++ lib/serve.ts | 206 +++++++---- lib/storage.ts | 2 +- lib/template.ts | 88 +++-- lib/types.d.ts | 32 +- lib/validation.ts | 32 ++ package.json | 230 +++++++------ 22 files changed, 960 insertions(+), 611 deletions(-) create mode 100644 app/(app)/of/[frameId]/[handler]/[[query]]/route.ts create mode 100644 lib/lens-frames.ts create mode 100644 lib/openframes.ts create mode 100644 lib/validation.ts diff --git a/app/(app)/f/[frameId]/[handler]/[[...query]]/route.ts b/app/(app)/f/[frameId]/[handler]/[[...query]]/route.ts index 25bbae02..d3d4b344 100644 --- a/app/(app)/f/[frameId]/[handler]/[[...query]]/route.ts +++ b/app/(app)/f/[frameId]/[handler]/[[...query]]/route.ts @@ -1,59 +1,60 @@ -import { client } from '@/db/client' -import { frameTable, interactionTable } from '@/db/schema' -import type { BuildFrameData, FramePayload } from '@/lib/farcaster' -import { updateFrameStorage } from '@/lib/frame' -import { buildFramePage, validatePayload, validatePayloadAirstack } from '@/lib/serve' -import type { BaseConfig, BaseStorage } from '@/lib/types' -import { FrameError } from '@/sdk/error' -import templates from '@/templates' -import { waitUntil } from '@vercel/functions' -import { type InferSelectModel, eq } from 'drizzle-orm' -import { notFound } from 'next/navigation' -import type { NextRequest } from 'next/server' - -export const dynamic = 'force-dynamic' -export const dynamicParams = true -export const fetchCache = 'force-no-store' +import { NextRequest } from 'next/server'; +import { client } from '@/db/client'; +import { frameTable, interactionTable } from '@/db/schema'; +import type { BuildFrameData, FramePayload } from '@/lib/farcaster'; +import { updateFrameStorage } from '@/lib/frame'; +import { buildFramePage } from '@/lib/serve'; +import type { BaseConfig, BaseStorage } from '@/lib/types'; +import { FrameError } from '@/sdk/error'; +import templates from '@/templates'; +import { waitUntil } from '@vercel/functions'; +import { type InferSelectModel, eq } from 'drizzle-orm'; +import { notFound } from 'next/navigation'; +import { validateFramePayload } from '@/lib/validation'; + +export const dynamic = 'force-dynamic'; +export const dynamicParams = true; +export const fetchCache = 'force-no-store'; export async function POST( request: NextRequest, { params }: { params: { frameId: string; handler: string } } ) { - const searchParams: Record = {} + const searchParams: Record = {}; request.nextUrl.searchParams.forEach((value, key) => { if (!['frameId', 'handler'].includes(key)) { - searchParams[key] = value + searchParams[key] = value; } - }) + }); const frame = await client .select() .from(frameTable) .where(eq(frameTable.id, params.frameId)) - .get() + .get(); if (!frame) { - notFound() + notFound(); } if (!frame.config) { - notFound() + notFound(); } - const template = templates[frame.template] + const template = templates[frame.template]; - const payload = (await request.json()) as FramePayload + const payload = (await request.json()) as FramePayload; - const handlerFn = template.handlers[params.handler as keyof typeof template.handlers] + const handlerFn = template.handlers[params.handler as keyof typeof template.handlers]; if (!handlerFn) { - notFound() + notFound(); } - - const validatedPayload = await validatePayload(payload) - let buildParameters = {} as BuildFrameData + const validatedPayload = await validateFramePayload(payload); + + let buildParameters = {} as BuildFrameData; try { buildParameters = await handlerFn({ @@ -61,7 +62,7 @@ export async function POST( config: frame.config as BaseConfig, storage: frame.storage as BaseStorage, params: searchParams, - }) + }); } catch (error) { if (error instanceof FrameError) { return Response.json( @@ -69,42 +70,42 @@ export async function POST( { status: 400, } - ) + ); } - console.error(error) + console.error(error); return Response.json( { message: 'Unknown error' }, { status: 500, } - ) + ); } if (buildParameters.transaction) { - waitUntil(processFrame(frame, buildParameters, payload)) + waitUntil(processFrame(frame, buildParameters, payload)); return new Response(JSON.stringify(buildParameters.transaction), { headers: { 'Content-Type': 'application/json', }, - }) + }); } const renderedFrame = await buildFramePage({ id: frame.id, linkedPage: frame.linkedPage || undefined, ...(buildParameters as BuildFrameData), - }) + }); - waitUntil(processFrame(frame, buildParameters, payload)) + waitUntil(processFrame(frame, buildParameters, payload)); return new Response(renderedFrame, { headers: { 'Content-Type': 'text/html', }, - }) + }); } async function processFrame( @@ -112,22 +113,22 @@ async function processFrame( parameters: BuildFrameData, payload: FramePayload ) { - const storageData = parameters.storage as BaseStorage | undefined + const storageData = parameters.storage as BaseStorage | undefined; if (storageData) { - await updateFrameStorage(frame.id, storageData) + await updateFrameStorage(frame.id, storageData); } if (frame.webhooks) { - const webhookUrls = frame.webhooks + const webhookUrls = frame.webhooks; if (!webhookUrls) { - return + return; } for (const webhook of parameters?.webhooks || []) { if (!webhookUrls?.[webhook.event]) { - continue + continue; } fetch(webhookUrls[webhook.event], { @@ -142,34 +143,28 @@ async function processFrame( }), }) .then(() => { - console.log('Sent webhook') + console.log('Sent webhook'); }) .catch((e) => { - console.error('Error sending webhook', e) - }) + console.error('Error sending webhook', e); + }); } } - const airstackKey = frame.config?.airstackKey || process.env.AIRSTACK_API_KEY - - const airstackPayloadValidated = await validatePayloadAirstack(payload, airstackKey) - - console.log(JSON.stringify(airstackPayloadValidated, null, 2)) + const validatedPayload = await validateFramePayload(payload); await client .insert(interactionTable) .values({ frame: frame.id, - fid: airstackPayloadValidated.message.data.fid.toString(), - buttonIndex: - airstackPayloadValidated.message.data.frameActionBody.buttonIndex.toString(), - inputText: airstackPayloadValidated.message.data.frameActionBody.inputText || undefined, - state: airstackPayloadValidated.message.data.frameActionBody.state || undefined, - transactionHash: - airstackPayloadValidated.message.data.frameActionBody.transactionId || undefined, - castFid: airstackPayloadValidated.message.data.frameActionBody.castId.fid.toString(), - castHash: airstackPayloadValidated.message.data.frameActionBody.castId.hash, + fid: validatedPayload.interactor.fid.toString(), + buttonIndex: validatedPayload.tapped_button.index.toString(), + inputText: validatedPayload.input?.text || undefined, + state: validatedPayload.state?.serialized || undefined, + transactionHash: validatedPayload.transactionId || undefined, + castFid: validatedPayload.cast.fid.toString(), + castHash: validatedPayload.cast.hash, createdAt: new Date(), }) - .run() -} + .run(); +} \ No newline at end of file diff --git a/app/(app)/f/[frameId]/interactions/route.ts b/app/(app)/f/[frameId]/interactions/route.ts index 9931ca54..4ec9a615 100644 --- a/app/(app)/f/[frameId]/interactions/route.ts +++ b/app/(app)/f/[frameId]/interactions/route.ts @@ -10,4 +10,4 @@ export async function GET(request: Request, { params }: { params: { frameId: str .all() return Response.json(interactions) -} \ No newline at end of file +} diff --git a/app/(app)/f/[frameId]/show/page.tsx b/app/(app)/f/[frameId]/show/page.tsx index 24bec9f5..e6632b4a 100644 --- a/app/(app)/f/[frameId]/show/page.tsx +++ b/app/(app)/f/[frameId]/show/page.tsx @@ -42,4 +42,4 @@ export default async function ShowPage({ params }: { params: { frameId: string } ) -} \ No newline at end of file +} diff --git a/app/(app)/loading.tsx b/app/(app)/loading.tsx index d81c051b..ed9e1a2f 100644 --- a/app/(app)/loading.tsx +++ b/app/(app)/loading.tsx @@ -1,4 +1,3 @@ - import { Progress } from '@/components/shadcn/Progress' const FUNNY_MESSAGES = [ diff --git a/app/(app)/of/[frameId]/[handler]/[[query]]/route.ts b/app/(app)/of/[frameId]/[handler]/[[query]]/route.ts new file mode 100644 index 00000000..14d01b51 --- /dev/null +++ b/app/(app)/of/[frameId]/[handler]/[[query]]/route.ts @@ -0,0 +1,174 @@ +import { NextRequest } from 'next/server'; +import { client } from '@/db/client'; +import { frameTable, interactionTable } from '@/db/schema'; +import type { BuildFrameData, FrameRequest } from '@/lib/farcaster'; +import { updateFrameStorage } from '@/lib/frame'; +import { buildFramePage } from '@/lib/serve'; +import type { BaseConfig, BaseStorage } from '@/lib/types'; +import { FrameError } from '@/sdk/error'; +import templates from '@/templates'; +import { waitUntil } from '@vercel/functions'; +import { type InferSelectModel, eq } from 'drizzle-orm'; +import { notFound } from 'next/navigation'; +import { validateFramePayload } from '@/lib/validation'; +import { buildOpenFrameMetadata } from '@/lib/openframe'; + +export const dynamic = 'force-dynamic'; +export const dynamicParams = true; +export const fetchCache = 'force-no-store'; + +export async function POST( + request: NextRequest, + { params }: { params: { frameId: string; handler: string } } +) { + const searchParams: Record = {}; + + request.nextUrl.searchParams.forEach((value, key) => { + if (!['frameId', 'handler'].includes(key)) { + searchParams[key] = value; + } + }); + + const frame = await client + .select() + .from(frameTable) + .where(eq(frameTable.id, params.frameId)) + .get(); + + if (!frame) { + notFound(); + } + + if (!frame.config) { + notFound(); + } + + const template = templates[frame.template]; + + const payload = (await request.json()) as FrameRequest; + + const handlerFn = template.handlers[params.handler as keyof typeof template.handlers]; + + if (!handlerFn) { + notFound(); + } + + const validatedPayload = await validateFramePayload(payload); + + let buildParameters = {} as BuildFrameData; + + try { + buildParameters = await handlerFn({ + body: validatedPayload, + config: frame.config as BaseConfig, + storage: frame.storage as BaseStorage, + params: searchParams, + }); + } catch (error) { + if (error instanceof FrameError) { + return Response.json( + { message: error.message }, + { + status: 400, + } + ); + } + + console.error(error); + + return Response.json( + { message: 'Unknown error' }, + { + status: 500, + } + ); + } + + if (buildParameters.transaction) { + waitUntil(processFrame(frame, buildParameters, payload)); + + return new Response(JSON.stringify(buildParameters.transaction), { + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + const openFrameMetadata = buildOpenFrameMetadata(buildParameters); + + const renderedFrame = await buildFramePage({ + id: frame.id, + linkedPage: frame.linkedPage || undefined, + ...(buildParameters as BuildFrameData), + openFrameMetadata, + }); + + waitUntil(processFrame(frame, buildParameters, payload)); + + return new Response(renderedFrame, { + headers: { + 'Content-Type': 'text/html', + }, + }); +} + +async function processFrame( + frame: InferSelectModel, + parameters: BuildFrameData, + payload: FrameRequest +) { + const storageData = parameters.storage as BaseStorage | undefined; + + if (storageData) { + await updateFrameStorage(frame.id, storageData); + } + + if (frame.webhooks) { + const webhookUrls = frame.webhooks; + + if (!webhookUrls) { + return; + } + + for (const webhook of parameters?.webhooks || []) { + if (!webhookUrls?.[webhook.event]) { + continue; + } + + fetch(webhookUrls[webhook.event], { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + event: webhook.event, + data: { + ...webhook.data, + createdAt: new Date().toISOString(), + }, + }), + }) + .then(() => { + console.log('Sent webhook'); + }) + .catch((e) => { + console.error('Error sending webhook', e); + }); + } + } + + const validatedPayload = await validateFramePayload(payload); + + await client + .insert(interactionTable) + .values({ + frame: frame.id, + fid: validatedPayload.interactor.fid.toString(), + buttonIndex: validatedPayload.tapped_button.index.toString(), + inputText: validatedPayload.input?.text || undefined, + state: validatedPayload.state?.serialized || undefined, + transactionHash: validatedPayload.transactionId || undefined, + castFid: validatedPayload.cast.fid.toString(), + castHash: validatedPayload.cast.hash, + createdAt: new Date(), + }) + .run(); +} \ No newline at end of file diff --git a/app/(app)/p/[frameId]/[handler]/[[...query]]/route.ts b/app/(app)/p/[frameId]/[handler]/[[...query]]/route.ts index 36df1240..08009627 100644 --- a/app/(app)/p/[frameId]/[handler]/[[...query]]/route.ts +++ b/app/(app)/p/[frameId]/[handler]/[[...query]]/route.ts @@ -1,67 +1,79 @@ -import { client } from '@/db/client' -import { frameTable } from '@/db/schema' -import type { BuildFrameData, FramePayloadValidated } from '@/lib/farcaster' -import { updateFramePreview } from '@/lib/frame' -import { buildPreviewFramePage } from '@/lib/serve' -import type { BaseConfig, BaseStorage } from '@/lib/types' -import { FrameError } from '@/sdk/error' -import templates from '@/templates' -import { eq } from 'drizzle-orm' -import ms from 'ms' -import { notFound } from 'next/navigation' -import type { NextRequest } from 'next/server' - -export const dynamic = 'force-dynamic' -export const dynamicParams = true -export const fetchCache = 'force-no-store' +import { NextRequest } from 'next/server'; +import { client } from '@/db/client'; +import { frameTable } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { notFound } from 'next/navigation'; +import templates from '@/templates'; +import { FrameError } from '@/sdk/error'; +import { buildPreviewFramePage } from '@/lib/serve'; +import type { FrameRequest, FramePayloadValidated } from '@/lib/farcaster'; +import { LensFrameRequest } from '@/lib/lens-frames'; +import { validateFramePayload } from '@/lib/validation'; +import ms from 'ms'; +import { updateFramePreview } from '@/lib/frame'; + +export const dynamic = 'force-dynamic'; +export const dynamicParams = true; +export const fetchCache = 'force-no-store'; export async function POST( request: NextRequest, { params }: { params: { frameId: string; handler: string } } ) { - const searchParams: Record = {} + const searchParams: Record = {}; request.nextUrl.searchParams.forEach((value, key) => { if (!['frameId', 'handler'].includes(key)) { - searchParams[key] = value + searchParams[key] = value; } - }) + }); const frame = await client .select() .from(frameTable) .where(eq(frameTable.id, params.frameId)) - .get() + .get(); if (!frame) { - notFound() + notFound(); } if (!frame.draftConfig) { - notFound() + notFound(); } - const template = templates[frame.template] + const template = templates[frame.template]; - const validatedPayload = (await request.json()) as FramePayloadValidated + const payload = (await request.json()) as FrameRequest | LensFrameRequest; - type ValidHandler = Omit + type ValidHandler = Omit; - const handlerFn = template.handlers[params.handler as keyof ValidHandler] + const handlerFn = template.handlers[params.handler as keyof ValidHandler]; if (!handlerFn) { - notFound() + notFound(); } - let buildParameters = {} as BuildFrameData + let validatedPayload: FramePayloadValidated; + + try { + validatedPayload = await validateFramePayload(payload); + } catch (error) { + return Response.json( + { message: error instanceof Error ? error.message : 'Invalid frame payload' }, + { status: 400 } + ); + } + + let buildParameters; try { buildParameters = await handlerFn({ body: validatedPayload, - config: frame.draftConfig as BaseConfig, - storage: frame.storage as BaseStorage, + config: frame.draftConfig, + storage: frame.storage, params: searchParams, - }) + }); } catch (error) { if (error instanceof FrameError) { return Response.json( @@ -69,15 +81,17 @@ export async function POST( { status: 400, } - ) + ); } + console.error(error); + return Response.json( { message: 'Unknown error' }, { status: 500, } - ) + ); } const renderedFrame = await buildPreviewFramePage({ @@ -91,18 +105,18 @@ export async function POST( component: buildParameters.component, image: buildParameters.image, handler: buildParameters.handler, - }) + }); if ( frame.updatedAt.getTime() < Date.now() - ms('5m') || frame.updatedAt.getTime() === frame.createdAt.getTime() ) { - await updateFramePreview(frame.id, renderedFrame) + await updateFramePreview(frame.id, renderedFrame); } return new Response(renderedFrame, { headers: { 'Content-Type': 'text/html', }, - }) -} + }); +} \ No newline at end of file diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index cb8b263f..f33af6ed 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -11,12 +11,12 @@ import NextLink from 'next/link' export default async function Home() { const sesh = await auth() - - let templates: - | Awaited> - | Awaited> = [] - - let recentFrames: Awaited> = [] + + let templates: + | Awaited> + | Awaited> = [] + + let recentFrames: Awaited> = [] if (sesh?.user) { recentFrames = await getRecentFrameList() diff --git a/app/(app)/templates/page.tsx b/app/(app)/templates/page.tsx index 3edfeef3..8e468d75 100644 --- a/app/(app)/templates/page.tsx +++ b/app/(app)/templates/page.tsx @@ -1,4 +1,3 @@ - import AccountButton from '@/components/foundation/AccountButton' import TemplateCard from '@/components/home/TemplateCard' import { getTemplates } from '@/lib/template' @@ -37,8 +36,8 @@ export const metadata: Metadata = { } export default async function TemplateList() { - const templates = await getTemplates() - + const templates = await getTemplates() + return (
diff --git a/app/layout.tsx b/app/layout.tsx index c92517d0..892138b9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,3 @@ - import type { Metadata } from 'next' import { SessionProvider } from 'next-auth/react' import type React from 'react' diff --git a/app/loading.tsx b/app/loading.tsx index d81c051b..ed9e1a2f 100644 --- a/app/loading.tsx +++ b/app/loading.tsx @@ -1,4 +1,3 @@ - import { Progress } from '@/components/shadcn/Progress' const FUNNY_MESSAGES = [ diff --git a/app/manifest.ts b/app/manifest.ts index 52e1ac59..4bc65eae 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -23,4 +23,4 @@ export default function manifest(): MetadataRoute.Manifest { }, ], } -} \ No newline at end of file +} diff --git a/app/robots.ts b/app/robots.ts index 71041251..e72611f4 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -9,4 +9,4 @@ export default function robots(): MetadataRoute.Robots { }, sitemap: 'https://frametra.in/sitemap.xml', } -} \ No newline at end of file +} diff --git a/lib/farcaster.d.ts b/lib/farcaster.d.ts index f841f5ea..c91ba474 100644 --- a/lib/farcaster.d.ts +++ b/lib/farcaster.d.ts @@ -49,6 +49,7 @@ export interface BuildFrameData { data: Record }[] transaction?: TransactionTargetResponse + openFrameMetadata?: OpenFrameMetadata } export interface FrameData { @@ -67,13 +68,6 @@ export interface FrameData { url: string } -export interface FrameRequest { - untrustedData: FrameData - trustedData: { - messageBytes: string - } -} - export type FrameImageMetadata = { src: string aspectRatio?: '1.91:1' | '1:1' @@ -87,3 +81,40 @@ export type FrameMetadataType = { refreshPeriod?: number state?: object } + +export interface OpenFrameMetadata { + version: string + accepts: Record + image: string + buttons?: OpenFrameButton[] + postUrl?: string + inputText?: string + imageAspectRatio?: '1.91:1' | '1:1' + imageAlt?: string + state?: string +} + +export interface OpenFrameButton { + label: string + action?: 'post' | 'post_redirect' | 'link' | 'mint' | 'tx' + target?: string + postUrl?: string +} + +export type ClientProtocol = 'lens' | 'xmtp' | 'anonymous' | 'farcaster' + +export interface FrameRequest { + clientProtocol: string + untrustedData: { + url: string + unixTimestamp: number + buttonIndex: number + inputText?: string + state?: string + address?: string + transactionId?: string + } + trustedData?: { + messageBytes: string + } +} diff --git a/lib/frame.ts b/lib/frame.ts index dc52ee2c..abb70c06 100644 --- a/lib/frame.ts +++ b/lib/frame.ts @@ -8,322 +8,130 @@ import { revalidatePath } from 'next/cache' import { notFound } from 'next/navigation' import { uploadPreview } from './storage' -export async function getFrameList() { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } - - const frames = await client - .select({ - ...getTableColumns(frameTable), - interactionCount: count(interactionTable.id), - }) - .from(frameTable) - .where(eq(frameTable.owner, sesh.user.id!)) - .leftJoin(interactionTable, eq(frameTable.id, interactionTable.frame)) - .groupBy(frameTable.id) - .orderBy(desc(frameTable.updatedAt)) - .all() +type Frame = InferInsertModel +type FrameId = Frame['id'] +type UserId = Frame['owner'] - return frames -} - -export async function getRecentFrameList() { +const ensureAuth = async (): Promise => { const sesh = await auth() + if (!sesh?.user) notFound() + return sesh.user.id! +} - if (!sesh?.user) { - notFound() - } +const getFrameQuery = (userId: UserId, frameId?: FrameId) => { + let query = client.select().from(frameTable).where(eq(frameTable.owner, userId)) + if (frameId) query = query.where(eq(frameTable.id, frameId)) + return query +} - const frames = await client +export async function getFrameList(limit?: number) { + const userId = await ensureAuth() + const query = client .select({ ...getTableColumns(frameTable), interactionCount: count(interactionTable.id), }) .from(frameTable) - .where(eq(frameTable.owner, sesh.user.id!)) - .limit(8) + .where(eq(frameTable.owner, userId)) .leftJoin(interactionTable, eq(frameTable.id, interactionTable.frame)) .groupBy(frameTable.id) .orderBy(desc(frameTable.updatedAt)) - return frames -} - -export async function getFrame(id: string) { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } + if (limit) query.limit(limit) - const frame = await client - .select() - .from(frameTable) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) - .get() + return query.all() +} - if (!frame) { - notFound() - } +export const getRecentFrameList = () => getFrameList(8) +export async function getFrame(id: FrameId) { + const userId = await ensureAuth() + const frame = await getFrameQuery(userId, id).get() + if (!frame) notFound() return frame } -export async function duplicateFrame(id: string) { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } - +export async function duplicateFrame(id: FrameId) { + const userId = await ensureAuth() const frame = await getFrame(id) - - const args: InferInsertModel = { - owner: sesh.user.id!, + const newFrame: Frame = { + ...frame, + owner: userId, name: `${frame.name} Copy`, description: frame.name, config: templates[frame.template].initialConfig, draftConfig: frame.draftConfig, storage: {}, - template: frame.template, } - - await client.insert(frameTable).values(args).run() - + await client.insert(frameTable).values(newFrame).run() revalidatePath('/') } -export async function createFrame({ - name, - description, - template, -}: { - name: string - description?: string - template: keyof typeof templates -}) { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } - - const args: InferInsertModel = { - owner: sesh.user.id!, - name, - description, - config: templates[template].initialConfig, - draftConfig: templates[template].initialConfig, +export async function createFrame(frameData: Pick) { + const userId = await ensureAuth() + const newFrame: Frame = { + owner: userId, + config: templates[frameData.template].initialConfig, + draftConfig: templates[frameData.template].initialConfig, storage: {}, - template: template, + ...frameData, } - - const frame = await client.insert(frameTable).values(args).returning().get() - - return frame + return client.insert(frameTable).values(newFrame).returning().get() } -export async function updateFrameName(id: string, name: string) { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } - +async function updateFrame(id: FrameId, updates: Partial) { + const userId = await ensureAuth() await client .update(frameTable) - .set({ name }) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) + .set(updates) + .where(and(eq(frameTable.id, id), eq(frameTable.owner, userId))) .run() - revalidatePath(`/frame/${id}`) } -export async function updateFrameConfig(id: string, config: any) { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } - - await client - .update(frameTable) - .set({ draftConfig: config }) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) - .run() - - revalidatePath(`/frame/${id}`) -} - -export async function publishFrameConfig(id: string) { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } - - const frame = await client - .select() - .from(frameTable) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) - .get() - - if (!frame) { - notFound() - } +export const updateFrameName = (id: FrameId, name: string) => updateFrame(id, { name }) +export const updateFrameConfig = (id: FrameId, config: any) => + updateFrame(id, { draftConfig: config }) +export async function publishFrameConfig(id: FrameId) { + const frame = await getFrame(id) const newDraftConfig = frame.draftConfig! - - // if (newDraftConfig.gating) { - // // disables advanced options that have no requirements set - // // enabled this once revalidation works - // const gating = frame.draftConfig!.gating as GatingType - // for (const enabledOption of gating.enabled) { - // if ( - // GATING_ADVANCED_OPTIONS.includes(enabledOption) && - // !gating.requirements?.[enabledOption as keyof GatingType['requirements']] - // ) { - // newDraftConfig.gating.enabled = newDraftConfig.gating.enabled.filter( - // (option: string) => option !== enabledOption - // ) - // } - // } - // } - - await client - .update(frameTable) - .set({ config: newDraftConfig, draftConfig: newDraftConfig }) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) - .run() - - // revalidatePath(`/frame/${id}`) + await updateFrame(id, { config: newDraftConfig, draftConfig: newDraftConfig }) } -export async function updateFrameLinkedPage(id: string, url?: string) { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } - - const frame = await client - .select() - .from(frameTable) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) - .get() - - if (!frame) { - notFound() - } - - await client - .update(frameTable) - .set({ linkedPage: url }) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) - .run() - - revalidatePath(`/frame/${id}`) -} - -export async function updateFrameWebhooks(id: string, webhook: { event: string; url?: string }) { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } - - const frame = await client - .select() - .from(frameTable) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) - .get() - - if (!frame) { - notFound() - } - - const webhooks = Object.assign({}, frame.webhooks) +export const updateFrameLinkedPage = (id: FrameId, url?: string) => + updateFrame(id, { linkedPage: url }) +export async function updateFrameWebhooks(id: FrameId, webhook: { event: string; url?: string }) { + const frame = await getFrame(id) + const webhooks = { ...frame.webhooks } if (webhook.url) { webhooks[webhook.event] = webhook.url } else { delete webhooks[webhook.event] } - - await client - .update(frameTable) - .set({ - webhooks, - }) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) - .run() - - revalidatePath(`/frame/${id}`) -} - -export async function revertFrameConfig(id: string) { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } - - const frame = await client - .select() - .from(frameTable) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) - .get() - - if (!frame) { - notFound() - } - - await client - .update(frameTable) - .set({ draftConfig: frame.config }) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) - .run() - - // revalidatePath(`/frame/${id}`) + await updateFrame(id, { webhooks }) } -export async function updateFrameStorage(id: string, storage: any) { - await client.update(frameTable).set({ storage }).where(eq(frameTable.id, id)).run() -} - -export async function updateFrameCalls(id: string, calls: number) { - await client - .update(frameTable) - .set({ currentMonthCalls: calls }) - .where(eq(frameTable.id, id)) - .run() +export async function revertFrameConfig(id: FrameId) { + const frame = await getFrame(id) + await updateFrame(id, { draftConfig: frame.config }) } -export async function updateFramePreview(id: string, preview: string) { - // extract whats after "property="og:image" content="data:image/png;base64," from preview - let previewImage = preview.split('data:image/png;base64,')[1] - previewImage = previewImage.split('"')[0] +export const updateFrameStorage = (id: FrameId, storage: any) => updateFrame(id, { storage }) +export const updateFrameCalls = (id: FrameId, calls: number) => + updateFrame(id, { currentMonthCalls: calls }) - await uploadPreview({ - frameId: id, - base64String: previewImage, - }) +export async function updateFramePreview(id: FrameId, preview: string) { + const previewImage = preview.split('data:image/png;base64,')[1].split('"')[0] + await uploadPreview({ frameId: id, base64String: previewImage }) } -export async function deleteFrame(id: string) { - const sesh = await auth() - - if (!sesh?.user) { - notFound() - } - +export async function deleteFrame(id: FrameId) { + const userId = await ensureAuth() await client .delete(frameTable) - .where(and(eq(frameTable.id, id), eq(frameTable.owner, sesh.user.id!))) + .where(and(eq(frameTable.id, id), eq(frameTable.owner, userId))) .run() - revalidatePath('/') } diff --git a/lib/lens-frames.ts b/lib/lens-frames.ts new file mode 100644 index 00000000..af16b897 --- /dev/null +++ b/lib/lens-frames.ts @@ -0,0 +1,158 @@ +import { FramePayloadValidated } from './farcaster'; + +export interface LensFrameRequest { + clientProtocol: 'lens'; + untrustedData: { + profileId: string; + pubId: string; + url: string; + unixTimestamp: number; + buttonIndex: number; + inputText?: string; + deadline?: number; + state?: string; + actionResponse?: string; + }; + trustedData: { + messageBytes: string; + identityToken?: string; + signerType?: 'owner' | 'delegatedExecutor'; + signer?: string; + }; +} + + + +export interface LensFrameValidationResult { + isValid: boolean; + validatedPayload?: FramePayloadValidated; + error?: string; +} + + + +export async function validateLensFrame(request: LensFrameRequest): Promise { + try { + const lensValidation = await validateLensFrameMessage(request.trustedData.messageBytes); + + if (!lensValidation.valid) { + return { + isValid: false, + error: 'Invalid Lens frame message', + }; + } + + const validatedPayload: FramePayloadValidated = { + object: 'validated_frame_action', + url: request.untrustedData.url, + interactor: { + object: 'user', + fid: parseInt(request.untrustedData.profileId), + custody_address: request.trustedData.signer || '', + username: '', + display_name: '', + pfp_url: '', + profile: { + bio: { + text: '', + mentioned_profiles: [], + }, + }, + follower_count: 0, + following_count: 0, + verifications: [], + verified_addresses: { + eth_addresses: [], + sol_addresses: [], + }, + active_status: 'inactive', + power_badge: false, + viewer_context: { + following: false, + followed_by: false, + }, + }, + tapped_button: { index: request.untrustedData.buttonIndex }, + state: { + serialized: request.untrustedData.state || '', + }, + cast: { + object: 'cast', + hash: request.untrustedData.pubId, + fid: parseInt(request.untrustedData.profileId), + author: { + object: 'user', + fid: parseInt(request.untrustedData.profileId), + custody_address: request.trustedData.signer || '', + username: '', + display_name: '', + pfp_url: '', + profile: { + bio: { + text: '', + mentioned_profiles: [], + }, + }, + follower_count: 0, + following_count: 0, + verifications: [], + verified_addresses: { + eth_addresses: [], + sol_addresses: [], + }, + active_status: 'inactive', + power_badge: false, + }, + text: '', + timestamp: new Date(request.untrustedData.unixTimestamp).toISOString(), + embeds: [], + reactions: { likes_count: 0, recasts_count: 0, likes: [], recasts: [] }, + replies: { count: 0 }, + mentioned_profiles: [], + viewer_context: { liked: false, recasted: false }, + }, + timestamp: new Date(request.untrustedData.unixTimestamp).toISOString(), + signer: { + client: { + object: 'user', + fid: parseInt(request.untrustedData.profileId), + custody_address: request.trustedData.signer || '', + username: '', + display_name: '', + pfp_url: '', + profile: { + bio: { + text: '', + mentioned_profiles: [], + }, + }, + follower_count: 0, + following_count: 0, + verifications: [], + verified_addresses: { + eth_addresses: [], + sol_addresses: [], + }, + active_status: 'inactive', + power_badge: false, + }, + }, + }; + + if (request.untrustedData.inputText) { + validatedPayload.input = { + text: request.untrustedData.inputText, + }; + } + + return { + isValid: true, + validatedPayload, + }; + } catch (error) { + return { + isValid: false, + error: error instanceof Error ? error.message : 'Unknown error during Lens frame validation', + }; + } +} \ No newline at end of file diff --git a/lib/openframes.ts b/lib/openframes.ts new file mode 100644 index 00000000..8123a34f --- /dev/null +++ b/lib/openframes.ts @@ -0,0 +1,45 @@ +import { BuildFrameData } from "./farcaster" + +export type ClientProtocol = 'lens' | 'xmtp' | 'anonymous' | 'farcaster' + +export interface OpenFrameMetadata { + version: string + accepts: Record + image: string + buttons?: OpenFrameButton[] + postUrl?: string + inputText?: string + imageAspectRatio?: '1.91:1' | '1:1' + imageAlt?: string + state?: string +} + +export interface OpenFrameButton { + label: string + action?: 'post' | 'post_redirect' | 'link' | 'mint' | 'tx' + target?: string + postUrl?: string +} + + +export function buildOpenFrameMetadata(buildFrameData: BuildFrameData): OpenFrameMetadata { + return { + version: 'vNext', + accepts: { + farcaster: 'vNext', + lens: '1.0.0', + xmtp: 'vNext', + anonymous: '1.0.0', + }, + image: buildFrameData.image, + buttons: buildFrameData.buttons?.map((button) => ({ + label: button.label, + action: button.action as 'post' | 'post_redirect' | 'link' | 'mint' | 'tx', + target: button.target, + })), + postUrl: buildFrameData.postUrl, + inputText: buildFrameData.inputText, + imageAspectRatio: buildFrameData.aspectRatio, + state: buildFrameData.state, + }; +} \ No newline at end of file diff --git a/lib/serve.ts b/lib/serve.ts index 338d953e..3334a7a0 100644 --- a/lib/serve.ts +++ b/lib/serve.ts @@ -7,6 +7,7 @@ import type { FrameButtonMetadata, FramePayload, FramePayloadValidated, + OpenFrameMetadata, } from './farcaster' export async function buildFramePage({ @@ -60,7 +61,7 @@ export async function buildFramePage({ .join('&') : '' - const metadata = await buildFrame({ + const farcasterMetadata = await buildFrame({ buttons, image: imageData, aspectRatio, @@ -71,24 +72,58 @@ export async function buildFramePage({ searchParams: searchParams, }) + const openFrameMetadataFinal = openFrameMetadata || { + version: 'vNext', + accepts: { + farcaster: 'vNext', + lens: '1.0.0', + xmtp: 'vNext', + anonymous: '1.0.0', + }, + image: imageData, + buttons: buttons?.map((button) => ({ + label: button.label, + action: button.action, + target: button.target, + })), + postUrl: `${process.env.NEXT_PUBLIC_HOST}/of/${id}`, + inputText, + imageAspectRatio: aspectRatio, + } + + const openFramesMetadata = buildFrame({ + buttons, + image: imageData, + aspectRatio, + inputText, + refreshPeriod, + postUrl: `${process.env.NEXT_PUBLIC_HOST}/of/${id}`, + handler: handler, + searchParams: searchParams, + openFrameMetadata: openFrameMetadataFinal, + }) + const frame = ` - - ${Object.keys(metadata) - .map((key) => ``) - .join('\n')} - 🚂 FrameTrain - ${linkedPage ? `` : ''} - - - -

🚂 Hello from FrameTrain

- - - ` + + ${Object.keys(farcasterMetadata) + .map((key) => ``) + .join('\n')} + ${Object.keys(openFramesMetadata) + .map((key) => ``) + .join('\n')} + 🚂 FrameTrain + ${linkedPage ? `` : ''} + + + +

🚂 Hello from FrameTrain

+ + + ` return frame } @@ -170,6 +205,7 @@ export async function buildFrame({ version = 'vNext', handler, searchParams, + openFrameMetadata, }: { buttons: FrameButtonMetadata[] image: string @@ -180,68 +216,104 @@ export async function buildFrame({ version?: string handler?: string searchParams?: string + openFrameMetadata?: OpenFrameMetadata }) { - // Regular expression to match the pattern YYYY-MM-DD - if (!(version === 'vNext' || /^\d{4}-\d{2}-\d{2}$/.test(version))) { - throw new Error('Invalid version.') - } + if (openFrameMetadata) { + metadata['of:version'] = openFrameMetadata.version + metadata['of:image'] = openFrameMetadata.image + metadata['of:image:aspect_ratio'] = openFrameMetadata.imageAspectRatio || aspectRatio + metadata['of:post_url'] = openFrameMetadata.postUrl || postUrl + + Object.entries(openFrameMetadata.accepts).forEach(([protocol, version]) => { + metadata[`of:accepts:${protocol}`] = version + }) - const metadata: Record = { - 'fc:frame': version, - // 'og:image': image, - 'fc:frame:image': image, - 'fc:frame:image:aspect_ratio': aspectRatio, - 'fc:frame:post_url': postUrl + `/${handler}` + '?' + searchParams, - } + if (openFrameMetadata.buttons) { + openFrameMetadata.buttons.forEach((button, index) => { + metadata[`of:button:${index + 1}`] = button.label + if (button.action) metadata[`of:button:${index + 1}:action`] = button.action + if (button.target) metadata[`of:button:${index + 1}:target`] = button.target + if (button.postUrl) metadata[`of:button:${index + 1}:post_url`] = button.postUrl + }) + } + + if (openFrameMetadata.inputText) { + metadata['of:input:text'] = openFrameMetadata.inputText + } + + if (openFrameMetadata.imageAlt) { + metadata['of:image:alt'] = openFrameMetadata.imageAlt + } - if (inputText) { - if (inputText.length > 32) { - throw new Error('Input text exceeds maximum length of 32 bytes.') + if (openFrameMetadata.state) { + metadata['of:state'] = openFrameMetadata.state } - metadata['fc:frame:input:text'] = inputText } - if (buttons) { - if (buttons.length > 4) { - throw new Error('Maximum of 4 buttons allowed.') + return metadata +} + +// Regular expression to match the pattern YYYY-MM-DD +if (!(version === 'vNext' || /^\d{4}-\d{2}-\d{2}$/.test(version))) { + throw new Error('Invalid version.') +} + +const metadata: Record = { + 'fc:frame': version, + // 'og:image': image, + 'fc:frame:image': image, + 'fc:frame:image:aspect_ratio': aspectRatio, + 'fc:frame:post_url': postUrl + `/${handler}` + '?' + searchParams, +} + +if (inputText) { + if (inputText.length > 32) { + throw new Error('Input text exceeds maximum length of 32 bytes.') + } + metadata['fc:frame:input:text'] = inputText +} + +if (buttons) { + if (buttons.length > 4) { + throw new Error('Maximum of 4 buttons allowed.') + } + buttons.forEach((button: FrameButtonMetadata, index: number) => { + if (!button.label || button.label.length > 256) { + throw new Error('Button label is required and must be maximum of 256 bytes.') } - buttons.forEach((button: FrameButtonMetadata, index: number) => { - if (!button.label || button.label.length > 256) { - throw new Error('Button label is required and must be maximum of 256 bytes.') - } - metadata[`fc:frame:button:${index + 1}`] = button.label + metadata[`fc:frame:button:${index + 1}`] = button.label - if (button.action) { - if (!['post', 'post_redirect', 'mint', 'link', 'tx'].includes(button.action)) { - throw new Error('Invalid button action.') - } - metadata[`fc:frame:button:${index + 1}:action`] = button.action - - if (button.action === 'tx') { - metadata[`fc:frame:button:${index + 1}:target`] = - postUrl + `/${button.handler || handler}` + '?' + searchParams - if (button.callback) { - metadata[`fc:frame:button:${index + 1}:post_url`] = - postUrl + `/${button.callback}` + '?' + searchParams - } - } - if (button.action === 'link' || button.action === 'mint') { - metadata[`fc:frame:button:${index + 1}:target`] = button.target + if (button.action) { + if (!['post', 'post_redirect', 'mint', 'link', 'tx'].includes(button.action)) { + throw new Error('Invalid button action.') + } + metadata[`fc:frame:button:${index + 1}:action`] = button.action + + if (button.action === 'tx') { + metadata[`fc:frame:button:${index + 1}:target`] = + postUrl + `/${button.handler || handler}` + '?' + searchParams + if (button.callback) { + metadata[`fc:frame:button:${index + 1}:post_url`] = + postUrl + `/${button.callback}` + '?' + searchParams } - } else { - metadata[`fc:frame:button:${index + 1}:action`] = 'post' // Default action } - }) - } - - if (refreshPeriod) { - if (refreshPeriod < 0) { - throw new Error('Refresh period must be a positive number.') + if (button.action === 'link' || button.action === 'mint') { + metadata[`fc:frame:button:${index + 1}:target`] = button.target + } + } else { + metadata[`fc:frame:button:${index + 1}:action`] = 'post' // Default action } - metadata['fc:frame:refresh_period'] = refreshPeriod.toString() + }) +} + +if (refreshPeriod) { + if (refreshPeriod < 0) { + throw new Error('Refresh period must be a positive number.') } + metadata['fc:frame:refresh_period'] = refreshPeriod.toString() +} - return metadata +return metadata } export async function validatePayload(body: FramePayload): Promise { diff --git a/lib/storage.ts b/lib/storage.ts index da34802d..ac463aa1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -51,4 +51,4 @@ export async function uploadPreview({ } catch (error) { // console.log('[uploadPreview] Failed to upload ', `frames/${frameId}/preview.png`, error) } -} \ No newline at end of file +} diff --git a/lib/template.ts b/lib/template.ts index 3a2b9e8a..6c004e23 100644 --- a/lib/template.ts +++ b/lib/template.ts @@ -2,78 +2,70 @@ import templates from '@/templates' import ms from 'ms' import { unstable_cache } from 'next/cache' +export type Template = { + id: string + name: string + description: string + shortDescription: string + icon: string + cover: string + creatorFid: string + creatorName: string + enabled: boolean + requiresValidation: boolean +} + export const getFeaturedTemplates = unstable_cache( - async () => { - const FEATURED_IDS = ['cal', 'figma', 'poll', 'pdf'] + async (): Promise => { + const FEATURED_IDS = ['cal', 'figma', 'poll', 'pdf', 'podcast', 'nft'] as const const featuredTemplates = Object.entries(templates) - .filter(([id, template]) => FEATURED_IDS.includes(id)) - .map(([id, template]) => { - return { - id: id, - name: template.name, - description: template.description, - shortDescription: template.shortDescription, - icon: template.icon, - cover: template.cover, - creatorFid: template.creatorFid, - creatorName: template.creatorName, - enabled: template.enabled, - requiresValidation: template.requiresValidation, - } - }) + .filter(([id]) => FEATURED_IDS.includes(id as (typeof FEATURED_IDS)[number])) + .map( + ([id, template]) => + ({ + ...template, + id, + }) as unknown as Template + ) return featuredTemplates }, [], { - revalidate: ms('1d') / 1000, + tags: ['templates'], + revalidate: ms('1m') / 1000, } ) export const getTemplates = unstable_cache( - async () => { - const allTemplates = Object.entries(templates).map(([id, template]) => { - return { - id: id, - name: template.name, - description: template.description, - shortDescription: template.shortDescription, - icon: template.icon, - cover: template.cover, - creatorFid: template.creatorFid, - creatorName: template.creatorName, - enabled: template.enabled, - requiresValidation: template.requiresValidation, - } - }) - - return allTemplates + async (): Promise => { + return Object.entries(templates).map( + ([id, template]) => + ({ + ...template, + id, + }) as unknown as Template + ) }, [], { - revalidate: ms('1d') / 1000, + tags: ['templates'], + revalidate: ms('1m') / 1000, } ) export const getTemplate = unstable_cache( - async (id: keyof typeof templates) => { + async (id: T): Promise