Introduce cloudflare workers to generate static previews (#179)

Use cloudflare's functions to generate and inject static metadata as
well as noscript content for crawlers and previews
This commit is contained in:
Evgeny Kuzyakov 2023-08-02 16:36:16 -07:00 committed by GitHub
parent 9d33d8821f
commit e4dde5449f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 337 additions and 12 deletions

1
.gitignore vendored
View File

@ -18,6 +18,7 @@
.env.development.local
.env.test.local
.env.production.local
.wrangler/
npm-debug.log*
yarn-debug.log*

View File

@ -0,0 +1,323 @@
import { Buffer } from "node:buffer";
class MetaTitleInjector {
constructor({ title }) {
this.title = title;
}
element(element) {
element.setAttribute("content", this.title);
}
}
class MetaImageInjector {
constructor({ image, authorImage }) {
this.image = image;
this.authorImage = authorImage;
}
element(element) {
if (this.image) {
element.setAttribute("content", this.image);
} else if (this.authorImage) {
element.setAttribute("content", this.authorImage);
}
}
}
class MetaTwitterCardInjector {
constructor({ image }) {
this.image = image;
}
element(element) {
if (!this.image) {
element.setAttribute("content", "summary");
}
}
}
class MetaDescriptionInjector {
constructor({ shortDescription }) {
this.shortDescription = shortDescription;
}
element(element) {
element.setAttribute(
"content",
this.shortDescription?.replaceAll("\n", " ")
);
}
}
class TitleInjector {
constructor({ title }) {
this.title = title;
}
element(element) {
element.setInnerContent(this.title);
}
}
class NoscriptDescriptionInjector {
constructor({ description }) {
this.description = description;
}
element(element) {
element.setInnerContent(this.description);
}
}
function defaultData() {
const image = "https://near.social/assets/logo.png";
const title = "Near Social";
const description = "Social data protocol built on NEAR";
return {
image,
title,
description,
};
}
async function socialGet(keys, blockHeight, parse) {
const request = await fetch("https://api.near.social/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
keys: [keys],
blockHeight,
}),
});
let data = await request.json();
const parts = keys.split("/");
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part === "*" || part === "**") {
break;
}
data = data?.[part];
}
if (parse) {
try {
data = JSON.parse(data);
} catch (e) {
return null;
}
}
return data;
}
async function viewCall({ contractId, method, args }) {
const res = await fetch("https://rpc.mainnet.near.org", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: "dontcare",
method: "query",
params: {
request_type: "call_function",
finality: "final",
account_id: contractId,
method_name: method,
args_base64: btoa(JSON.stringify(args)),
},
}),
});
const json = await res.json();
const result = Buffer.from(json.result.result).toString("utf-8");
return JSON.parse(result);
}
async function nftToImageUrl({ contractId, tokenId }) {
const [token, nftMetadata] = await Promise.all([
viewCall({
contractId,
method: "nft_token",
args: { token_id: tokenId },
}),
viewCall({
contractId,
method: "nft_metadata",
args: {},
}),
]);
const tokenMetadata = token?.metadata || {};
const tokenMedia = tokenMetadata.media || "";
let imageUrl =
tokenMedia.startsWith("https://") ||
tokenMedia.startsWith("http://") ||
tokenMedia.startsWith("data:image")
? tokenMedia
: nftMetadata.base_uri
? `${nftMetadata.base_uri}/${tokenMedia}`
: tokenMedia.startsWith("Qm") || tokenMedia.startsWith("ba")
? `https://ipfs.near.social/ipfs/${tokenMedia}`
: tokenMedia;
if (!tokenMedia && tokenMetadata.reference) {
const metadataUrl =
nftMetadata.base_uri === "https://arweave.net" &&
!tokenMetadata.reference.startsWith("https://")
? `${nftMetadata.base_uri}/${tokenMetadata.reference}`
: tokenMetadata.reference.startsWith("https://") ||
tokenMetadata.reference.startsWith("http://")
? tokenMetadata.reference
: tokenMetadata.reference.startsWith("ar://")
? `https://arweave.net/${tokenMetadata.reference.split("//")[1]}`
: null;
if (metadataUrl) {
const res = await fetch(metadataUrl);
const json = await res.json();
imageUrl = json.media;
}
}
return imageUrl;
}
function wrapImage(url) {
return url ? `https://i.near.social/large/${url}` : null;
}
async function internalImageToUrl(env, image) {
if (image?.url) {
return image.url;
} else if (image?.ipfs_cid) {
return `https://ipfs.near.social/ipfs/${image.ipfs_cid}`;
} else if (image?.nft) {
try {
const { contractId, tokenId } = image.nft;
const NftKV = env.NftKV;
let imageUrl = await NftKV.get(`${contractId}/${tokenId}`);
if (!imageUrl) {
imageUrl = await nftToImageUrl({ contractId, tokenId });
if (imageUrl) {
await NftKV.put(`${contractId}/${tokenId}`, imageUrl);
}
}
return imageUrl;
} catch (e) {
console.log(e);
}
}
return null;
}
async function imageToUrl(env, image) {
return wrapImage(await internalImageToUrl(env, image));
}
async function postData(env, url, data, isPost) {
const accountId = url.searchParams.get("accountId");
const blockHeight = url.searchParams.get("blockHeight");
const [content, name, authorImage] = await Promise.all([
socialGet(
`${accountId}/post/${isPost ? "main" : "comment"}`,
blockHeight,
true
),
socialGet(`${accountId}/profile/name`),
socialGet(`${accountId}/profile/image/**`),
]);
data.raw = content;
data.description = content?.text || "";
data.image = await imageToUrl(env, content?.image);
if (!data.image) {
data.authorImage = await imageToUrl(env, authorImage);
}
data.title = isPost
? `Post by ${name ?? accountId} | Near Social`
: `Comment by ${name ?? accountId} | Near Social`;
data.accountName = name;
data.accountId = accountId;
}
async function profileData(env, url, data) {
const accountId = url.searchParams.get("accountId");
const profile = await socialGet(`${accountId}/profile/**`);
const name = profile?.name;
data.raw = profile;
data.description =
profile?.description || `Profile of ${accountId} on Near Social`;
data.image = await imageToUrl(env, profile?.image);
data.authorImage =
data.image ||
wrapImage(
"https://ipfs.near.social/ipfs/bafkreibmiy4ozblcgv3fm3gc6q62s55em33vconbavfd2ekkuliznaq3zm"
);
data.title = name
? `${name} (${accountId}) | Near Social`
: `${accountId} | Near Social`;
data.accountName = name;
data.accountId = accountId;
}
async function widgetData(env, url, data) {
const parts = url.pathname.split("/");
const accountId = parts[1];
const widgetId = parts[3];
const metadata = await socialGet(
`${accountId}/widget/${widgetId}/metadata/**`
);
const name = metadata?.name || widgetId;
data.raw = metadata;
data.description =
metadata?.description || `Component ${name} created by ${accountId}`;
data.image = await imageToUrl(env, metadata?.image);
data.title = `${name} by ${accountId} | Near Social`;
data.accountName = name;
data.accountId = accountId;
}
async function generateData(env, url) {
const data = defaultData();
try {
if (url.pathname === "/mob.near/widget/MainPage.Post.Page") {
await postData(env, url, data, true);
} else if (url.pathname === "/mob.near/widget/MainPage.Comment.Page") {
await postData(env, url, data, false);
} else if (url.pathname === "/mob.near/widget/ProfilePage") {
await profileData(env, url, data);
} else {
await widgetData(env, url, data);
}
} catch (e) {
console.error(e);
}
data.shortDescription = data.description.slice(0, 250);
return data;
}
export async function onRequest({ request, next, env }) {
const url = new URL(request.url);
if (url.pathname.split("/").length < 4) {
return next();
}
const data = await generateData(env, url);
return (
new HTMLRewriter()
.on('meta[property="og:title"]', new MetaTitleInjector(data))
.on('meta[property="og:image"]', new MetaImageInjector(data))
.on('meta[name="twitter:card"]', new MetaTwitterCardInjector(data))
.on('meta[property="og:description"]', new MetaDescriptionInjector(data))
.on('meta[name="description"]', new MetaDescriptionInjector(data))
// .on("head", new MetaTagInjector(data))
.on("title", new TitleInjector(data))
.on("noscript", new NoscriptDescriptionInjector(data))
.transform(await next())
);
}

View File

@ -5,27 +5,28 @@
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="Social data protocol built on NEAR"
/>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@NearSocial_">
<meta name="twitter:image" content="https://near.social/assets/logo.png">
<meta property="og:image" content="https://near.social/assets/logo.png">
<meta property="og:type" content="website">
<meta property="og:title" content="Near Social" />
<meta property="og:description" content="Social data protocol built on NEAR" />
<link rel="apple-touch-icon" href="/favicon.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="/manifest.json" />
<meta
name="description"
content="Social data protocol built on NEAR"
/>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@NearSocial_">
<meta property="og:image" content="https://near.social/assets/logo.png">
<meta property="og:type" content="website">
<meta property="og:title" content="Near Social" />
<meta property="og:description" content="Social data protocol built on NEAR" />
<title>Near Social</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>