diff --git a/.gitignore b/.gitignore index 5fa790fc..53ecedb0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Appview appview/dist +feeds/dist .next node_modules diff --git a/disco.json b/disco.json index 76f9434d..0549cde8 100644 --- a/disco.json +++ b/disco.json @@ -1,6 +1,10 @@ { "version": "1.0", "services": { + "web": { + "image": "feed-service", + "port": 3000 + }, "worker": { "image": "appview", "volumes": [ @@ -12,6 +16,9 @@ } }, "images": { + "feed-service": { + "dockerfile": "feeds/Dockerfile" + }, "appview": { "dockerfile": "appview/Dockerfile" } diff --git a/feeds/Dockerfile b/feeds/Dockerfile new file mode 100644 index 00000000..2eab635f --- /dev/null +++ b/feeds/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22 +WORKDIR /code + +# start with dependencies to enjoy caching +COPY ./package.json /code/package.json +COPY ./package-lock.json /code/package-lock.json +RUN npm ci + +# copy rest and build +COPY . /code/. +RUN npm run build-feed-service +CMD ["node", "/code/feeds/dist/index.js"] diff --git a/feeds/index.ts b/feeds/index.ts new file mode 100644 index 00000000..aab5754e --- /dev/null +++ b/feeds/index.ts @@ -0,0 +1,67 @@ +import { Hono, HonoRequest } from "hono"; +import { serve } from "@hono/node-server"; +import { DidResolver } from "@atproto/identity"; +import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; +import { supabaseServerClient } from "supabase/serverClient"; +import { PubLeafletDocument } from "lexicons/api"; + +const app = new Hono(); + +const domain = "feeds.leaflet.pub"; +const serviceDid = `did:web:${domain}`; + +app.get("/.well-known/did.json", (c) => { + return c.json({ + "@context": ["https://www.w3.org/ns/did/v1"], + id: serviceDid, + service: [ + { + id: "#bsky_fg", + type: "BskyFeedGenerator", + serviceEndpoint: `https://${domain}`, + }, + ], + }); +}); + +app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => { + let auth = await validateAuth(c.req, serviceDid); + if (!auth) return c.json({ feed: [] }); + + let { data: publications } = await supabaseServerClient + .from("publication_subscriptions") + .select(`publications(documents_in_publications(documents(*)))`) + .eq("identity", auth); + const feed = (publications || []).flatMap((pub) => { + let posts = pub.publications?.documents_in_publications || []; + return posts.flatMap((p) => { + if (!p.documents?.data) return []; + let record = p.documents.data as PubLeafletDocument.Record; + if (!record.postRef) return []; + return { post: record.postRef.uri }; + }); + }); + + return c.json({ + feed, + }); +}); + +const didResolver = new DidResolver({}); +const validateAuth = async ( + req: HonoRequest, + serviceDid: string, +): Promise => { + const authorization = req.header("authorization"); + if (!authorization?.startsWith("Bearer ")) { + return null; + } + const jwt = authorization.replace("Bearer ", "").trim(); + const nsid = parseReqNsid(req); + const parsed = await verifyJwt(jwt, serviceDid, nsid, async (did: string) => { + return didResolver.resolveAtprotoKey(did); + }); + return parsed.iss; +}; + +serve(app); diff --git a/package-lock.json b/package-lock.json index c6094bf4..3cb9d5a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@atproto/sync": "^0.1.23", "@atproto/syntax": "^0.3.3", "@atproto/xrpc": "^0.6.9", + "@hono/node-server": "^1.14.3", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", "@next/bundle-analyzer": "^15.3.2", @@ -38,6 +39,7 @@ "drizzle-orm": "^0.30.10", "feed": "^5.0.1", "fractional-indexing": "^3.2.0", + "hono": "^4.7.11", "linkifyjs": "^4.2.0", "multiformats": "^13.3.2", "next": "15.3.2", @@ -1841,6 +1843,18 @@ "tslib": "^2.8.0" } }, + "node_modules/@hono/node-server": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.14.3.tgz", + "integrity": "sha512-KuDMwwghtFYSmIpr4WrKs1VpelTrptvJ+6x6mbUcZnFcc213cumTF5BdqfHyW93B19TNI4Vaev14vOI2a0Ie3w==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -10160,6 +10174,15 @@ "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", "dev": true }, + "node_modules/hono": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.7.11.tgz", + "integrity": "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", diff --git a/package.json b/package.json index cdf4fe63..00259378 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;", "wrangler-dev": "wrangler dev", "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", + "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", "start-appview-dev": "tsx --env-file='./.env.local' --watch appview/index.ts", "start-appview-prod": "npm run build-appview && node appview/dist/index.js" }, @@ -24,6 +25,7 @@ "@atproto/sync": "^0.1.23", "@atproto/syntax": "^0.3.3", "@atproto/xrpc": "^0.6.9", + "@hono/node-server": "^1.14.3", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", "@next/bundle-analyzer": "^15.3.2", @@ -46,6 +48,7 @@ "drizzle-orm": "^0.30.10", "feed": "^5.0.1", "fractional-indexing": "^3.2.0", + "hono": "^4.7.11", "linkifyjs": "^4.2.0", "multiformats": "^13.3.2", "next": "15.3.2",