Skip to content

Commit 4e99674

Browse files
Merge pull request #18 from depatchedmode/v0.7.0
v0.7.0 - Dynamic Frame Binding
2 parents 6691178 + 14da895 commit 4e99674

File tree

13 files changed

+171
-87
lines changed

13 files changed

+171
-87
lines changed

README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,26 @@
22

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

5+
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.
6+
57
### My needs for starting this were:
68

7-
1. **🚱 Zero Framework:** Didn't want a framework baked in, and most options default to Next.js/React
9+
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.
810
2. **🆓 Zero Cost:** Frames are for experiments! Experimenting is more fruitful when it's free.
911
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)
10-
4. **🤸 Dynamic Generation:** You can get all the above pretty easy with static files, but let's be real: we want dynamism!
11-
5. **🤤 Small:** and hopefully easy. Nobody to impress here.
12-
6. **😎 Cool Tech:** We want to be at the 🤬 edge here, people!
12+
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.
13+
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.
14+
6. **😎 Cool Tech:** We want to be at the 🤬 edge here, people! I admit this is somewhat in tension with "simplest".
15+
16+
### Features
17+
18+
+ **⑃ Flow Definition**: Define button & input behaviour within each frame file.
19+
+ **🎇 Static & Dynamic Images**: Support for both static & dynamic frame images.
20+
+ **🧐 Validate trustedData**: Verify the current payload's `trustedData` via Farcaster Hubs (eg. wield.co), to protect against tomfoolery.
21+
+ **⌨️ 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.
22+
+ **↗️ 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.
23+
+ **🎟️ 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.
24+
+ **🔐 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?
1325

1426
### Example
1527

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

2941
### Testing
42+
3043
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*.
3144
2. Test that link in the Warpcast Embed UI: https://warpcast.com/~/developers/embeds
3245

3346
### Defining your Frame
3447

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

3750
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.
3851

52+
*Y'all are welcome to help me write them.
53+
3954
### Deploying
55+
4056
This should be as simple as [watching a git repo for commits](https://docs.netlify.com/site-deploys/create-deploys/).
4157

4258
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:
@@ -48,6 +64,7 @@ I am a designer larping as a dev. I invite your collaboration and feedback. Plea
4864
And please! Can we make it simpler?
4965

5066
### Roadmap
67+
5168
1. Less bad
5269
2. More better
5370
3. Migration to the [everywhere.computer](https://everywhere.computer)

api/frame.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,19 @@ import { URLSearchParamsToObject } from '../modules/utils';
33

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

9-
if (frameSrc.image) {
10-
const image = `${process.env.URL}${frameSrc.image}`
9+
if (targetFrameSrc.image) {
10+
const image = `${process.env.URL}${targetFrameSrc.image}`
1111
return new Response(image,
1212
{
1313
status: 200,
1414
headers: { 'Content-Type': 'image/png' },
1515
}
1616
);
17-
} else if (frameSrc.build) {
18-
const markup = await frameSrc.build(frameData);
17+
} else if (targetFrameSrc.build) {
18+
const markup = await targetFrameSrc.build(params);
1919
return new Response(markup,
2020
{
2121
status: 200,

api/index.js

Lines changed: 42 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,41 @@ import buildButtons from '../modules/buildButtons';
55
import buildInputs from '../modules/buildInputs';
66
import getTargetFrame from '../modules/getTargetFrame';
77
import { validateMessage } from '../src/data/message';
8+
import { isFrameStolen } from '../src/data/antitheft';
89

910
export default async (req, context) => {
10-
let from = 'poster';
11-
let buttonId = null;
12-
const payload = await parseRequest(req);
13-
let isOriginal = true;
14-
15-
if (payload) {
11+
try {
1612
const requestURL = new URL(req.url);
17-
from = requestURL.searchParams.get('frame');
18-
buttonId = payload.untrustedData?.buttonIndex;
19-
isOriginal = isOriginalCast(payload.untrustedData.castId.hash);
20-
payload.referringFrame = from;
21-
payload.validData = await validateMessage(payload.trustedData.messageBytes);
22-
}
13+
const payload = await parseRequest(req);
14+
let from = requestURL.searchParams.get('frame');
15+
let buttonId = null;
16+
let frameIsStolen = false;
2317

24-
let { frameSrc, frameName, redirectUrl } = getTargetFrame(from,buttonId,frames);
25-
if (!isOriginal) {
26-
frameName = 'stolen';
27-
frameSrc = frames[frameName];
28-
}
18+
if (payload) {
19+
payload.referringFrame = from;
20+
payload.validData = await validateMessage(payload.trustedData.messageBytes);
21+
}
2922

30-
if (redirectUrl) {
31-
return await respondWithRedirect(redirectUrl);
32-
} else if (frameSrc) {
33-
return await respondWithFrame(frameName, frameSrc, payload);
34-
} else {
35-
console.error(`🤷🏻`)
36-
}
37-
}
23+
if (payload?.validData) {
24+
buttonId = payload.validData.data.frameActionBody.buttonIndex;
25+
frameIsStolen = await isFrameStolen(payload);
26+
}
3827

39-
const isOriginalCast = (currHash) => {
40-
const ogHash = process.env.BOUND_CAST_HASH;
41-
return ogHash ? currHash == ogHash : true;
42-
}
28+
const { targetFrameSrc, targetFrameName, redirectUrl } = getTargetFrame(from, buttonId, frames);
29+
30+
if (redirectUrl) {
31+
return await respondWithRedirect(redirectUrl);
32+
} else if (frameIsStolen) {
33+
return await respondWithFrame('stolen', frames['stolen'], payload);
34+
} else if (targetFrameSrc) {
35+
return await respondWithFrame(targetFrameName, targetFrameSrc, payload);
36+
} else {
37+
console.error(`Unknown frame requested: ${targetFrameName}`);
38+
}
39+
} catch (error) {
40+
console.error(`Error processing request: ${error}`);
41+
}
42+
};
4343

4444
const respondWithRedirect = (redirectUrl) => {
4545
const internalRedirectUrl = new URL(`${process.env.URL}/redirect`)
@@ -54,34 +54,20 @@ const respondWithRedirect = (redirectUrl) => {
5454
);
5555
}
5656

57-
const respondWithFrame = async (frameName, frameSrc, payload) => {
58-
const debug = process.env.DEBUG_MODE;
57+
const respondWithFrame = async (targetFrameName, targetFrameSrc, payload) => {
58+
const searchParams = {
59+
targetFrameName,
60+
payload
61+
}
5962
const host = process.env.URL;
60-
6163
const frameContent = {
62-
image: ``,
63-
buttons: frameSrc.buttons ? buildButtons(frameSrc.buttons) : [],
64-
inputs: frameSrc.inputs ? buildInputs(frameSrc.inputs) : [],
65-
postURL: `${host}/?frame=${frameName}`
66-
}
67-
68-
const frameData = {
69-
name: frameName,
70-
server: {
71-
host,
72-
debug
73-
},
74-
payload,
75-
}
76-
77-
if (frameSrc.image) {
78-
frameContent.image = `${host}${frameSrc.image}`;
79-
} else if (frameSrc.build) {
80-
const searchParams = objectToURLSearchParams(frameData);
81-
frameContent.image = `${host}/og-image?${searchParams}`;
82-
} else {
83-
console.error(`Each frame requires an image path or a build function`)
84-
}
64+
image: targetFrameSrc.image ?
65+
`${host}/${targetFrameSrc.image}` :
66+
`${host}/og-image?${objectToURLSearchParams(searchParams)}` || '',
67+
buttons: targetFrameSrc.buttons ? buildButtons(targetFrameSrc.buttons) : '',
68+
inputs: targetFrameSrc.inputs ? buildInputs(targetFrameSrc.inputs) : '',
69+
postURL: `${host}/?frame=${targetFrameName}`
70+
};
8571

8672
return new Response(await landingPage(frameContent),
8773
{
@@ -91,7 +77,7 @@ const respondWithFrame = async (frameName, frameSrc, payload) => {
9177
},
9278
}
9379
);
94-
}
80+
};
9581

9682
export const config = {
9783
path: "/"

api/og-image.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import { URLSearchParamsToObject } from '../modules/utils';
77

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

1414
const svg = await satori(
1515
html(markup),

modules/getTargetFrame.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
const DEFAULT_FRAME = 'poster';
22

33
export default (name, buttonId, frames) => {
4-
let frameName = DEFAULT_FRAME;
4+
let targetFrameName = DEFAULT_FRAME;
55
let redirectUrl = null;
66
if (name && buttonId) {
77
const originFrame = frames[name];
88
const button = originFrame.buttons[buttonId-1];
9-
frameName = button.goTo;
9+
targetFrameName = button.goTo;
1010
redirectUrl = button.url;
1111
}
12-
const frameSrc = frames[frameName];
12+
const targetFrameSrc = frames[targetFrameName];
1313
return {
14-
frameSrc,
15-
frameName,
14+
targetFrameSrc,
15+
targetFrameName,
1616
redirectUrl
1717
};
1818
}

sample.env

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ WIELD_API_KEY=
33

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

910
# This is a free hub. More info at https://docs.wield.co
1011
# It's used to validate the trusted data payload

src/data/antitheft.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { getStore } from '@netlify/blobs';
2+
3+
// Utility functions to abstract the fetching and setting operations
4+
const fetchData = async (key) => {
5+
const store = getStore('antiTheft');
6+
let data = await store.get(key, 'json') || [];
7+
if (!data.length) {
8+
data = process.env[key] ? JSON.parse(process.env[key]) : [];
9+
}
10+
return data;
11+
};
12+
13+
const setData = async (key, data) => {
14+
const store = getStore('antiTheft');
15+
await store.setJSON(key, data);
16+
return data;
17+
};
18+
19+
// Updated getters and setters using the utility functions
20+
const getBoundAccounts = () => fetchData('BOUND_ACCOUNT_IDS');
21+
const getBoundCasts = () => fetchData('BOUND_CAST_HASHES');
22+
const setBoundAccounts = (accountIDs) => setData('BOUND_ACCOUNT_IDS', accountIDs);
23+
const setBoundCasts = (castHashes) => setData('BOUND_CAST_HASHES', castHashes);
24+
25+
// Modified functions to use the updated getters and setters
26+
const addToList = async (getter, setter, item) => {
27+
let list = await getter();
28+
if (!list.includes(item)) {
29+
list.push(item);
30+
await setter(list);
31+
}
32+
};
33+
34+
const removeFromList = async (getter, setter, item) => {
35+
let list = await getter();
36+
const index = list.indexOf(item);
37+
if (index > -1) {
38+
list.splice(index, 1);
39+
await setter(list);
40+
}
41+
};
42+
43+
// Wrapper functions for specific operations
44+
const addBoundAccount = (accountId) => addToList(getBoundAccounts, setBoundAccounts, accountId);
45+
const removeBoundAccount = (accountId) => removeFromList(getBoundAccounts, setBoundAccounts, accountId);
46+
const addBoundCast = (castHash) => addToList(getBoundCasts, setBoundCasts, castHash);
47+
const removeBoundCast = (castHash) => removeFromList(getBoundCasts, setBoundCasts, castHash);
48+
49+
// Frame is allowed if:
50+
// 1. The castAuthorID is in boundAccounts.
51+
// 2. The castHash is in boundCasts.
52+
// 3. Both boundCasts & boundAccounts are empty.
53+
const isFrameStolen = async (payload) => {
54+
const { fid: castAuthorID, hash: castHash } = payload.validData.data.frameActionBody.castId;
55+
const boundCasts = await getBoundCasts();
56+
const boundAccounts = await getBoundAccounts();
57+
58+
const isAuthorAllowed = boundAccounts.includes(castAuthorID) || boundAccounts.length === 0;
59+
const isCastAllowed = boundCasts.includes(castHash) || boundCasts.length === 0;
60+
const isFirstParty = payload.untrustedData.url.indexOf(process.env.URL) > -1;
61+
62+
return !isFirstParty || !isAuthorAllowed || !isCastAllowed;
63+
};
64+
65+
export {
66+
getBoundAccounts,
67+
setBoundAccounts,
68+
addBoundAccount,
69+
removeBoundAccount,
70+
getBoundCasts,
71+
setBoundCasts,
72+
addBoundCast,
73+
removeBoundCast,
74+
isFrameStolen
75+
};

src/data/message.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ const validateMessage = async(messageBytes) => {
99
},
1010
body: Buffer.from(messageBytes, 'hex'),
1111
})
12-
.then(response => response.json() )
12+
.then(async(response) => {
13+
const parsedResponse = await response.json();
14+
return parsedResponse.valid ? parsedResponse.message : false;
15+
})
1316
.catch(error => console.error('Error:', error));
1417
}
1518

src/frames/count.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,14 @@ import { getFramer, setFramer } from '../data/framer';
33
import { getCount, incrementCount } from '../data/count';
44
import safeDecode from '../../modules/safeDecode';
55

6-
const build = async (frameData) => {
7-
const { payload } = frameData;
6+
const build = async (payload) => {
87
let count = await getCount();
98
const validData = payload?.validData;
10-
const isValid = validData?.valid;
119

12-
if (payload && isValid && payload.referringFrame == 'count') {
10+
if (payload.validData && payload.referringFrame == 'count') {
1311
count = await incrementCount(count);
14-
const tauntInput = validData.message.data.frameActionBody.inputText;
15-
await setFramer(validData.message.data.fid, tauntInput);
12+
const tauntInput = validData.data.frameActionBody.inputText;
13+
await setFramer(validData.data.fid, tauntInput);
1614
}
1715

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

40-
return mainLayout(frameData, frameHTML);
38+
return mainLayout(payload, frameHTML);
4139
}
4240

4341
export const inputs = [
@@ -59,6 +57,7 @@ export const buttons = [
5957
]
6058

6159
export default {
60+
name: 'stolen',
6261
build,
6362
buttons,
6463
inputs

src/frames/credits.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export default {
2+
name: 'credits',
23
image: `/images/credits.png`,
34
buttons: [
45
{

0 commit comments

Comments
 (0)