Hey is a decentralized and permissionless social media app built with Lens Protocol 🌿
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add posts API endpoint with rate limiting and authentication

yoginth.com 78e154e3 0ef78348

verified
+138 -9
+2
apps/api/src/index.ts
··· 9 9 import infoLogger from "./middlewares/infoLogger"; 10 10 import rateLimiter from "./middlewares/rateLimiter"; 11 11 import pageview from "./pageview"; 12 + import posts from "./posts"; 12 13 import cronRouter from "./routes/cron"; 13 14 import lensRouter from "./routes/lens"; 14 15 import metadataRouter from "./routes/metadata"; ··· 31 32 app.route("/oembed", oembedRouter); 32 33 app.route("/og", ogRouter); 33 34 app.post("/pageview", rateLimiter({ requests: 10 }), authMiddleware, pageview); 35 + app.post("/posts", rateLimiter({ requests: 10 }), authMiddleware, posts); 34 36 35 37 app.notFound((ctx) => 36 38 ctx.json({ error: "Not Found", status: Status.Error }, 404)
+1 -9
apps/api/src/pageview.ts
··· 1 1 import { Status } from "@hey/data/enums"; 2 2 import type { Context } from "hono"; 3 + import getIpData from "./utils/getIpData"; 3 4 4 5 interface PageviewBody { 5 6 path?: string; 6 7 } 7 - 8 - const getIpData = (ctx: Context) => { 9 - const h = (name: string) => ctx.req.header(name) ?? ""; 10 - return { 11 - city: h("cf-ipcity"), 12 - countryCode: h("cf-ipcountry"), 13 - region: h("cf-region") 14 - }; 15 - }; 16 8 17 9 const pageview = async (ctx: Context) => { 18 10 let body: PageviewBody = {};
+92
apps/api/src/posts.ts
··· 1 + import { Status } from "@hey/data/enums"; 2 + import type { Context } from "hono"; 3 + import getIpData from "./utils/getIpData"; 4 + 5 + interface PostsBody { 6 + slug?: string; 7 + title?: string; 8 + content?: string; 9 + type?: string; 10 + } 11 + 12 + const posts = async (ctx: Context) => { 13 + let body: PostsBody = {}; 14 + try { 15 + body = (await ctx.req.json()) as PostsBody; 16 + } catch { 17 + body = {}; 18 + } 19 + 20 + const ipData = getIpData(ctx); 21 + const host = ctx.req.header("host") ?? ""; 22 + const ts = new Date().toISOString(); 23 + 24 + if (host.includes("localhost")) { 25 + return ctx.json({ 26 + data: { ok: true, skipped: true }, 27 + status: Status.Success 28 + }); 29 + } 30 + 31 + const payload = { ...ipData, account: ctx.get("account"), host, ts }; 32 + 33 + try { 34 + const trunc = (v: string, max = 1024) => 35 + v.length > max ? `${v.slice(0, max - 1)}…` : v; 36 + 37 + const location = [payload.city, payload.region, payload.countryCode] 38 + .filter(Boolean) 39 + .join(", "); 40 + 41 + const fields: { inline?: boolean; name: string; value: string }[] = []; 42 + const add = (name: string, value?: string, inline?: boolean) => { 43 + if (value) fields.push({ inline, name, value: trunc(value) }); 44 + }; 45 + 46 + const postUrl = body.slug 47 + ? `https://hey.xyz/posts/${body.slug}` 48 + : undefined; 49 + 50 + add("Account", payload.account, true); 51 + add("Location", location, true); 52 + add("Type", body.type, true); 53 + add("URL", postUrl); 54 + add("Title", body.title); 55 + add("Content", body.content); 56 + 57 + const embed = { 58 + color: 0xfb3a5d, 59 + fields, 60 + thumbnail: { url: "https://github.com/heyverse.png" }, 61 + timestamp: payload.ts, 62 + title: body.title || body.slug || "New Post", 63 + url: postUrl 64 + }; 65 + 66 + const res = await fetch( 67 + "https://discord.com/api/webhooks/1419640499504943216/1nNNx7tezx59_gof-EAVWTFIAu3pT2oGZLKxO1dtpOcxM0P5JBEuU-4zvzo_ZF80TZhS", 68 + { 69 + body: JSON.stringify({ embeds: [embed] }), 70 + headers: { "content-type": "application/json" }, 71 + method: "POST" 72 + } 73 + ); 74 + 75 + if (res.status === 429) { 76 + console.warn("Discord webhook rate limited", { 77 + limit: res.headers.get("x-ratelimit-limit"), 78 + remaining: res.headers.get("x-ratelimit-remaining"), 79 + reset: res.headers.get("x-ratelimit-reset"), 80 + retryAfter: res.headers.get("retry-after") 81 + }); 82 + } 83 + 84 + console.log("Sent posts webhook", res.status); 85 + } catch (err) { 86 + console.error("Failed to send posts webhook", err); 87 + } 88 + 89 + return ctx.json({ data: { ok: true }, status: Status.Success }); 90 + }; 91 + 92 + export default posts;
+18
apps/api/src/utils/getIpData.ts
··· 1 + import type { Context } from "hono"; 2 + 3 + interface IpData { 4 + city: string; 5 + countryCode: string; 6 + region: string; 7 + } 8 + 9 + const getIpData = (ctx: Context): IpData => { 10 + const h = (name: string) => ctx.req.header(name) ?? ""; 11 + return { 12 + city: h("cf-ipcity"), 13 + countryCode: h("cf-ipcountry"), 14 + region: h("cf-region") 15 + }; 16 + }; 17 + 18 + export default getIpData;
+12
apps/web/src/helpers/fetcher.ts
··· 78 78 body: JSON.stringify({ path }), 79 79 method: "POST" 80 80 }) 81 + }, 82 + posts: { 83 + create: async (payload: { 84 + slug: string; 85 + title?: string; 86 + content?: string; 87 + type?: string; 88 + }) => 89 + fetchApi<{ ok: boolean; skipped?: boolean }>("/posts", { 90 + body: JSON.stringify(payload), 91 + method: "POST" 92 + }) 81 93 } 82 94 };
+13
apps/web/src/hooks/useCreatePost.tsx
··· 9 9 import { useCallback } from "react"; 10 10 import { useNavigate } from "react-router"; 11 11 import { toast } from "sonner"; 12 + import { hono } from "@/helpers/fetcher"; 12 13 import useTransactionLifecycle from "./useTransactionLifecycle"; 13 14 import useWaitForTransactionToComplete from "./useWaitForTransactionToComplete"; 14 15 ··· 39 40 40 41 if (!data?.post) { 41 42 return; 43 + } 44 + 45 + // Fire-and-forget webhook only for new posts (not comments) 46 + if (!isComment) { 47 + try { 48 + void hono.posts.create({ 49 + content: undefined, 50 + slug: data.post.slug, 51 + title: undefined, 52 + type: data.post.__typename 53 + }); 54 + } catch {} 42 55 } 43 56 44 57 toast.success(`${isComment ? "Comment" : "Post"} created successfully!`, {