Skip to content

Commit

Permalink
Merge pull request #18 from depatchedmode/v0.7.0
Browse files Browse the repository at this point in the history
v0.7.0 - Dynamic Frame Binding
  • Loading branch information
depatchedmode authored Feb 5, 2024
2 parents 6691178 + 14da895 commit 4e99674
Show file tree
Hide file tree
Showing 13 changed files with 171 additions and 87 deletions.
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,26 @@

## A zero-cost, zero-framework, dynamic Farcaster Frame template

This is the "simplest" version of a Frame that can do all of the things a Frame should be able to do. It may be more than you need. It may be less. But it's a great place to start, IMHO.

### My needs for starting this were:

1. **🚱 Zero Framework:** Didn't want a framework baked in, and most options default to Next.js/React
1. **🚱 Zero Framework:** Didn't want a framework baked in, and most options default to Next.js/React. No offense, but those seem like overkill.
2. **🆓 Zero Cost:** Frames are for experiments! Experimenting is more fruitful when it's free.
3. **🧱 Stable:** The domain and its attached state should be reasonably stable over the horizon of an experiment. Replit can only give you this at cost (see above)
4. **🤸 Dynamic Generation:** You can get all the above pretty easy with static files, but let's be real: we want dynamism!
5. **🤤 Small:** and hopefully easy. Nobody to impress here.
6. **😎 Cool Tech:** We want to be at the 🤬 edge here, people!
4. **🤸 Dynamic:** You can get all the above pretty easy with static files, but let's be real: we want dynamism or something! And, as social animals, we want to act and react.
5. **🤤 Small:** and hopefully easy. Nobody to impress here. If my quite smooth brain can write this, your quite prune-like brain can understand it to.
6. **😎 Cool Tech:** We want to be at the 🤬 edge here, people! I admit this is somewhat in tension with "simplest".

### Features

+ **⑃ Flow Definition**: Define button & input behaviour within each frame file.
+ **🎇 Static & Dynamic Images**: Support for both static & dynamic frame images.
+ **🧐 Validate trustedData**: Verify the current payload's `trustedData` via Farcaster Hubs (eg. wield.co), to protect against tomfoolery.
+ **⌨️ Text inputs**: Accept that UGC with byte-level protection. Our `safeDecode` function leverages `dompurify` to give you a literal, and *helpful* purity test. The judgement of whether the content meets your standards is still up to you, though.
+ **↗️ Redirect Support:** Because frames can't do everything ... yet! And, doggonit, there's a whole *world* ~~*wide* *web*~~ out there for y'all to explore.
+ **🎟️ Mint from frame (COMING SOON):** Using Syndicate + Base, this boilerplate gives you what you need to make random interactions with your frame *unforgettable*. Is that a good idea? That sounds like a you problem.
+ **🔐 Anti-theft:** Don't bet the engagement farm! Bind your Frame to a specific cast or account so nobody else can get your likes, follows, recasts ... and respect. Capisci?

### Example

Expand All @@ -27,16 +39,20 @@ https://warpcast.com/depatchedmode/0xecad681e
5. `netlify dev`

### Testing

1. Run `netlify dev --live` will give [proxy your local machine](https://docs.netlify.com/cli/local-development/#share-a-live-development-server) to the *world* *wide* *web*.
2. Test that link in the Warpcast Embed UI: https://warpcast.com/~/developers/embeds

### Defining your Frame

We'll update with a proper docs soon, but you'll find everything you need in the `public` and `src` directories.
We'll update with a proper docs soon*, but you'll find everything you need in the `public` and `src` directories.

To add a new frame, create a `{frameName}.js` file in `/src/frames` and add it as an import to `/src/frames/index.js`. You'll find examples of dynamic (eg. rendered HTML) and static (eg. served from the public folder) frames in that directory.

*Y'all are welcome to help me write them.

### Deploying

This should be as simple as [watching a git repo for commits](https://docs.netlify.com/site-deploys/create-deploys/).

You may encounter a 502 gateway error after deployment on the `/og-image` endpoint. This is a known issue with the `sharp` module this repo relies upon. We'll hopefully have this fixed by default, but for now there are workarounds. Follow this thread for fixes:
Expand All @@ -48,6 +64,7 @@ I am a designer larping as a dev. I invite your collaboration and feedback. Plea
And please! Can we make it simpler?

### Roadmap

1. Less bad
2. More better
3. Migration to the [everywhere.computer](https://everywhere.computer)
12 changes: 6 additions & 6 deletions api/frame.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ import { URLSearchParamsToObject } from '../modules/utils';

export default async (req, context) => {
const url = new URL(req.url);
const frameData = URLSearchParamsToObject(url.searchParams);
const frameSrc = frames[frameData.name];
const params = URLSearchParamsToObject(url.searchParams);
const targetFrameSrc = frames[params.targetFrameName];

if (frameSrc.image) {
const image = `${process.env.URL}${frameSrc.image}`
if (targetFrameSrc.image) {
const image = `${process.env.URL}${targetFrameSrc.image}`
return new Response(image,
{
status: 200,
headers: { 'Content-Type': 'image/png' },
}
);
} else if (frameSrc.build) {
const markup = await frameSrc.build(frameData);
} else if (targetFrameSrc.build) {
const markup = await targetFrameSrc.build(params);
return new Response(markup,
{
status: 200,
Expand Down
98 changes: 42 additions & 56 deletions api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,41 @@ import buildButtons from '../modules/buildButtons';
import buildInputs from '../modules/buildInputs';
import getTargetFrame from '../modules/getTargetFrame';
import { validateMessage } from '../src/data/message';
import { isFrameStolen } from '../src/data/antitheft';

export default async (req, context) => {
let from = 'poster';
let buttonId = null;
const payload = await parseRequest(req);
let isOriginal = true;

if (payload) {
try {
const requestURL = new URL(req.url);
from = requestURL.searchParams.get('frame');
buttonId = payload.untrustedData?.buttonIndex;
isOriginal = isOriginalCast(payload.untrustedData.castId.hash);
payload.referringFrame = from;
payload.validData = await validateMessage(payload.trustedData.messageBytes);
}
const payload = await parseRequest(req);
let from = requestURL.searchParams.get('frame');
let buttonId = null;
let frameIsStolen = false;

let { frameSrc, frameName, redirectUrl } = getTargetFrame(from,buttonId,frames);
if (!isOriginal) {
frameName = 'stolen';
frameSrc = frames[frameName];
}
if (payload) {
payload.referringFrame = from;
payload.validData = await validateMessage(payload.trustedData.messageBytes);
}

if (redirectUrl) {
return await respondWithRedirect(redirectUrl);
} else if (frameSrc) {
return await respondWithFrame(frameName, frameSrc, payload);
} else {
console.error(`🤷🏻`)
}
}
if (payload?.validData) {
buttonId = payload.validData.data.frameActionBody.buttonIndex;
frameIsStolen = await isFrameStolen(payload);
}

const isOriginalCast = (currHash) => {
const ogHash = process.env.BOUND_CAST_HASH;
return ogHash ? currHash == ogHash : true;
}
const { targetFrameSrc, targetFrameName, redirectUrl } = getTargetFrame(from, buttonId, frames);

if (redirectUrl) {
return await respondWithRedirect(redirectUrl);
} else if (frameIsStolen) {
return await respondWithFrame('stolen', frames['stolen'], payload);
} else if (targetFrameSrc) {
return await respondWithFrame(targetFrameName, targetFrameSrc, payload);
} else {
console.error(`Unknown frame requested: ${targetFrameName}`);
}
} catch (error) {
console.error(`Error processing request: ${error}`);
}
};

const respondWithRedirect = (redirectUrl) => {
const internalRedirectUrl = new URL(`${process.env.URL}/redirect`)
Expand All @@ -54,34 +54,20 @@ const respondWithRedirect = (redirectUrl) => {
);
}

const respondWithFrame = async (frameName, frameSrc, payload) => {
const debug = process.env.DEBUG_MODE;
const respondWithFrame = async (targetFrameName, targetFrameSrc, payload) => {
const searchParams = {
targetFrameName,
payload
}
const host = process.env.URL;

const frameContent = {
image: ``,
buttons: frameSrc.buttons ? buildButtons(frameSrc.buttons) : [],
inputs: frameSrc.inputs ? buildInputs(frameSrc.inputs) : [],
postURL: `${host}/?frame=${frameName}`
}

const frameData = {
name: frameName,
server: {
host,
debug
},
payload,
}

if (frameSrc.image) {
frameContent.image = `${host}${frameSrc.image}`;
} else if (frameSrc.build) {
const searchParams = objectToURLSearchParams(frameData);
frameContent.image = `${host}/og-image?${searchParams}`;
} else {
console.error(`Each frame requires an image path or a build function`)
}
image: targetFrameSrc.image ?
`${host}/${targetFrameSrc.image}` :
`${host}/og-image?${objectToURLSearchParams(searchParams)}` || '',
buttons: targetFrameSrc.buttons ? buildButtons(targetFrameSrc.buttons) : '',
inputs: targetFrameSrc.inputs ? buildInputs(targetFrameSrc.inputs) : '',
postURL: `${host}/?frame=${targetFrameName}`
};

return new Response(await landingPage(frameContent),
{
Expand All @@ -91,7 +77,7 @@ const respondWithFrame = async (frameName, frameSrc, payload) => {
},
}
);
}
};

export const config = {
path: "/"
Expand Down
6 changes: 3 additions & 3 deletions api/og-image.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { URLSearchParamsToObject } from '../modules/utils';

export default async (req, context) => {
const url = new URL(req.url);
const frameData = URLSearchParamsToObject(url.searchParams);
const frameSrc = frames[frameData.name];
const markup = await frameSrc.build(frameData);
const params = URLSearchParamsToObject(url.searchParams);
const targetFrameSrc = frames[params.targetFrameName];
const markup = await targetFrameSrc.build(params.payload);

const svg = await satori(
html(markup),
Expand Down
10 changes: 5 additions & 5 deletions modules/getTargetFrame.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
const DEFAULT_FRAME = 'poster';

export default (name, buttonId, frames) => {
let frameName = DEFAULT_FRAME;
let targetFrameName = DEFAULT_FRAME;
let redirectUrl = null;
if (name && buttonId) {
const originFrame = frames[name];
const button = originFrame.buttons[buttonId-1];
frameName = button.goTo;
targetFrameName = button.goTo;
redirectUrl = button.url;
}
const frameSrc = frames[frameName];
const targetFrameSrc = frames[targetFrameName];
return {
frameSrc,
frameName,
targetFrameSrc,
targetFrameName,
redirectUrl
};
}
5 changes: 3 additions & 2 deletions sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ WIELD_API_KEY=

# If you want your frame to only work when viewed in the
# context of a specific hash, with a URL to the original cast
BOUND_CAST_URL=
BOUND_CAST_HASH=
# BOUND_CAST_HASHES=["hash1","hash2"]
# BOUND_ACCOUNT_IDS=["2600"]
# STOLEN_REDIRECT_URL=

# This is a free hub. More info at https://docs.wield.co
# It's used to validate the trusted data payload
Expand Down
75 changes: 75 additions & 0 deletions src/data/antitheft.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { getStore } from '@netlify/blobs';

// Utility functions to abstract the fetching and setting operations
const fetchData = async (key) => {
const store = getStore('antiTheft');
let data = await store.get(key, 'json') || [];
if (!data.length) {
data = process.env[key] ? JSON.parse(process.env[key]) : [];
}
return data;
};

const setData = async (key, data) => {
const store = getStore('antiTheft');
await store.setJSON(key, data);
return data;
};

// Updated getters and setters using the utility functions
const getBoundAccounts = () => fetchData('BOUND_ACCOUNT_IDS');
const getBoundCasts = () => fetchData('BOUND_CAST_HASHES');
const setBoundAccounts = (accountIDs) => setData('BOUND_ACCOUNT_IDS', accountIDs);
const setBoundCasts = (castHashes) => setData('BOUND_CAST_HASHES', castHashes);

// Modified functions to use the updated getters and setters
const addToList = async (getter, setter, item) => {
let list = await getter();
if (!list.includes(item)) {
list.push(item);
await setter(list);
}
};

const removeFromList = async (getter, setter, item) => {
let list = await getter();
const index = list.indexOf(item);
if (index > -1) {
list.splice(index, 1);
await setter(list);
}
};

// Wrapper functions for specific operations
const addBoundAccount = (accountId) => addToList(getBoundAccounts, setBoundAccounts, accountId);
const removeBoundAccount = (accountId) => removeFromList(getBoundAccounts, setBoundAccounts, accountId);
const addBoundCast = (castHash) => addToList(getBoundCasts, setBoundCasts, castHash);
const removeBoundCast = (castHash) => removeFromList(getBoundCasts, setBoundCasts, castHash);

// Frame is allowed if:
// 1. The castAuthorID is in boundAccounts.
// 2. The castHash is in boundCasts.
// 3. Both boundCasts & boundAccounts are empty.
const isFrameStolen = async (payload) => {
const { fid: castAuthorID, hash: castHash } = payload.validData.data.frameActionBody.castId;
const boundCasts = await getBoundCasts();
const boundAccounts = await getBoundAccounts();

const isAuthorAllowed = boundAccounts.includes(castAuthorID) || boundAccounts.length === 0;
const isCastAllowed = boundCasts.includes(castHash) || boundCasts.length === 0;
const isFirstParty = payload.untrustedData.url.indexOf(process.env.URL) > -1;

return !isFirstParty || !isAuthorAllowed || !isCastAllowed;
};

export {
getBoundAccounts,
setBoundAccounts,
addBoundAccount,
removeBoundAccount,
getBoundCasts,
setBoundCasts,
addBoundCast,
removeBoundCast,
isFrameStolen
};
5 changes: 4 additions & 1 deletion src/data/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ const validateMessage = async(messageBytes) => {
},
body: Buffer.from(messageBytes, 'hex'),
})
.then(response => response.json() )
.then(async(response) => {
const parsedResponse = await response.json();
return parsedResponse.valid ? parsedResponse.message : false;
})
.catch(error => console.error('Error:', error));
}

Expand Down
13 changes: 6 additions & 7 deletions src/frames/count.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@ import { getFramer, setFramer } from '../data/framer';
import { getCount, incrementCount } from '../data/count';
import safeDecode from '../../modules/safeDecode';

const build = async (frameData) => {
const { payload } = frameData;
const build = async (payload) => {
let count = await getCount();
const validData = payload?.validData;
const isValid = validData?.valid;

if (payload && isValid && payload.referringFrame == 'count') {
if (payload.validData && payload.referringFrame == 'count') {
count = await incrementCount(count);
const tauntInput = validData.message.data.frameActionBody.inputText;
await setFramer(validData.message.data.fid, tauntInput);
const tauntInput = validData.data.frameActionBody.inputText;
await setFramer(validData.data.fid, tauntInput);
}

const { username, taunt } = await getFramer() || '';
Expand All @@ -37,7 +35,7 @@ const build = async (frameData) => {
</fc-frame>
`;

return mainLayout(frameData, frameHTML);
return mainLayout(payload, frameHTML);
}

export const inputs = [
Expand All @@ -59,6 +57,7 @@ export const buttons = [
]

export default {
name: 'stolen',
build,
buttons,
inputs
Expand Down
1 change: 1 addition & 0 deletions src/frames/credits.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export default {
name: 'credits',
image: `/images/credits.png`,
buttons: [
{
Expand Down
Loading

0 comments on commit 4e99674

Please sign in to comment.