a tool for shared writing and social publishing
1import { Hono, HonoRequest } from "hono";
2import { serve } from "@hono/node-server";
3import { DidResolver } from "@atproto/identity";
4import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server";
5import { supabaseServerClient } from "supabase/serverClient";
6import {
7 normalizeDocumentRecord,
8 type NormalizedDocument,
9} from "src/utils/normalizeRecords";
10import { inngest } from "app/api/inngest/client";
11import { AtUri } from "@atproto/api";
12
13const app = new Hono();
14
15const domain = process.env.FEED_SERVICE_URL || "feeds.leaflet.pub";
16const serviceDid = `did:web:${domain}`;
17
18app.get("/.well-known/did.json", (c) => {
19 return c.json({
20 "@context": ["https://www.w3.org/ns/did/v1"],
21 id: serviceDid,
22 service: [
23 {
24 id: "#bsky_fg",
25 type: "BskyFeedGenerator",
26 serviceEndpoint: `https://${domain}`,
27 },
28 ],
29 });
30});
31//Cursor format ts::uri
32
33app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => {
34 let auth = await validateAuth(c.req, serviceDid);
35 let feed = c.req.query("feed");
36 if (!auth || !feed) return c.json({ feed: [] });
37 let cursor = c.req.query("cursor");
38 let parsedCursor;
39 if (cursor) {
40 let date = cursor.split("::")[0];
41 let uri = cursor.split("::")[1];
42 parsedCursor = { date, uri };
43 }
44 let limit = parseInt(c.req.query("limit") || "10");
45 let feedAtURI = new AtUri(feed);
46 let posts;
47 let query;
48 if (feedAtURI.rkey == "bsky-leaflet-quotes") {
49 let query = supabaseServerClient
50 .from("document_mentions_in_bsky")
51 .select("*")
52 .order("indexed_at", { ascending: false })
53 .order("uri", { ascending: false })
54 .limit(25);
55 if (parsedCursor)
56 query = query.or(
57 `indexed_at.lt.${parsedCursor.date},and(indexed_at.eq.${parsedCursor.date},uri.lt.${parsedCursor.uri})`,
58 );
59
60 let { data, error } = await query;
61 let posts = data || [];
62
63 let lastPost = posts[posts.length - 1];
64 let newCursor = lastPost ? `${lastPost.indexed_at}::${lastPost.uri}` : null;
65 return c.json({
66 cursor: newCursor || cursor,
67 feed: posts.flatMap((p) => {
68 return { post: p.uri };
69 }),
70 });
71 }
72 if (feedAtURI.rkey === "bsky-follows-leaflets") {
73 if (!cursor) {
74 console.log("Sending event");
75 await inngest.send({ name: "feeds/index-follows", data: { did: auth } });
76 }
77 query = supabaseServerClient
78 .from("documents")
79 .select(
80 `*,
81 documents_in_publications!inner(
82 publications!inner(*,
83 identities!publications_identity_did_fkey!inner(
84 bsky_follows!bsky_follows_follows_fkey!inner(*)
85 )
86 )
87 )`,
88 )
89 .eq(
90 "documents_in_publications.publications.identities.bsky_follows.identity",
91 auth,
92 );
93 } else if (feedAtURI.rkey === "all-leaflets") {
94 query = supabaseServerClient
95 .from("documents")
96 .select(
97 `*,
98 documents_in_publications(publications(*))`,
99 )
100 .or(
101 "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true",
102 { referencedTable: "documents_in_publications.publications" },
103 );
104 } else {
105 //the default subscription feed
106 query = supabaseServerClient
107 .from("documents")
108 .select(
109 `*,
110 documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`,
111 )
112 .eq(
113 "documents_in_publications.publications.publication_subscriptions.identity",
114 auth,
115 );
116 }
117 query = query
118 .or("data->postRef.not.is.null,data->bskyPostRef.not.is.null")
119 .order("sort_date", { ascending: false })
120 .order("uri", { ascending: false })
121 .limit(25);
122 if (parsedCursor)
123 query = query.or(
124 `sort_date.lt.${parsedCursor.date},and(sort_date.eq.${parsedCursor.date},uri.lt.${parsedCursor.uri})`,
125 );
126
127 let { data, error } = await query;
128 console.log(error);
129 posts = data;
130
131 posts = posts || [];
132
133 let lastPost = posts[posts.length - 1];
134 let newCursor = lastPost ? `${lastPost.sort_date}::${lastPost.uri}` : null;
135 return c.json({
136 cursor: newCursor || cursor,
137 feed: posts.flatMap((p) => {
138 if (!p.data) return [];
139 const normalizedDoc = normalizeDocumentRecord(p.data, p.uri);
140 if (!normalizedDoc?.bskyPostRef) return [];
141 return { post: normalizedDoc.bskyPostRef.uri };
142 }),
143 });
144});
145
146const didResolver = new DidResolver({});
147const validateAuth = async (
148 req: HonoRequest,
149 serviceDid: string,
150): Promise<string | null> => {
151 const authorization = req.header("authorization");
152 if (!authorization?.startsWith("Bearer ")) {
153 return null;
154 }
155 const jwt = authorization.replace("Bearer ", "").trim();
156 const nsid = parseReqNsid({ url: req.path });
157 const parsed = await verifyJwt(jwt, serviceDid, nsid, async (did: string) => {
158 return didResolver.resolveAtprotoKey(did);
159 });
160 return parsed.iss;
161};
162
163serve({ fetch: app.fetch, port: 3030 });