The recipes.blue monorepo recipes.blue
recipes appview atproto
2
fork

Configure Feed

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

feat: update configs & lexicons

+179 -17
+1 -1
Dockerfile
··· 7 7 COPY . /usr/src/app 8 8 WORKDIR /usr/src/app 9 9 RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 10 - RUN pnpm run -r build 10 + RUN cd apps/api && pnpm run -r build 11 11 RUN pnpm deploy --filter=@cookware/api --prod /prod/api 12 12 13 13 FROM base AS api
+28 -11
apps/api/src/xrpc/index.ts
··· 2 2 import { db } from '../db/index.js'; 3 3 import { recipeTable } from '../db/schema.js'; 4 4 import { and, eq, sql } from 'drizzle-orm'; 5 - import { getDidDoc, parseDid } from '../util/did.js'; 5 + import { getDidDoc, getDidFromHandleOrDid } from '../util/did.js'; 6 6 7 7 export const xrpcApp = new Hono(); 8 8 ··· 17 17 authorDid: recipeTable.authorDid, 18 18 uri: sql`concat(${recipeTable.authorDid}, "/", ${recipeTable.rkey})`.as('uri'), 19 19 }).from(recipeTable); 20 + 21 + const results = []; 22 + const eachRecipe = async (r: typeof recipes[0]) => ({ 23 + author: await (async () => { 24 + const author = await getDidDoc(r.authorDid); 25 + return author.alsoKnownAs[0]?.substring(5); 26 + })(), 27 + rkey: r.rkey, 28 + did: r.authorDid, 29 + title: r.title, 30 + description: r.description, 31 + ingredients: r.ingredientsCount, 32 + steps: r.stepsCount , 33 + }); 34 + 35 + for (const result of recipes) { 36 + results.push(await eachRecipe(result)); 37 + } 20 38 21 39 return ctx.json({ 22 - recipes: recipes.map(r => ({ 23 - rkey: r.rkey, 24 - did: r.authorDid, 25 - title: r.title, 26 - description: r.description, 27 - ingredients: r.ingredientsCount, 28 - steps: r.stepsCount , 29 - })), 40 + recipes: results, 30 41 }); 31 42 }); 32 43 ··· 35 46 if (!did) throw new Error('Invalid DID'); 36 47 if (!rkey) throw new Error('Invalid rkey'); 37 48 38 - const parsedDid = parseDid(did); 39 - if (!parsedDid) throw new Error('Invalid DID'); 49 + let parsedDid = await getDidFromHandleOrDid(did); 50 + if (!parsedDid) { 51 + ctx.status(404); 52 + return ctx.json({ 53 + error: 'invalid_did', 54 + message: 'No such author was found by that identifier.', 55 + }); 56 + } 40 57 41 58 const recipe = await db.query.recipeTable.findFirst({ 42 59 where: and(
+2 -1
apps/web/package.json
··· 29 29 "lucide-react": "^0.464.0", 30 30 "react-dom": "19.0.0-rc-f994737d14-20240522", 31 31 "tailwind-merge": "^2.5.5", 32 - "tailwindcss-animate": "^1.0.7" 32 + "tailwindcss-animate": "^1.0.7", 33 + "zod": "^3.23.8" 33 34 }, 34 35 "devDependencies": { 35 36 "@atcute/bluesky": "^1.0.9",
+139
apps/web/src/lib/did.ts
··· 1 + import { z } from "zod"; 2 + 3 + type Brand<K, T> = K & { __brand: T }; 4 + export type DID = Brand<string, "DID">; 5 + 6 + export function isDid(s: string): s is DID { 7 + return s.startsWith("did:"); 8 + } 9 + 10 + export function parseDid(s: string): DID | null { 11 + if (!isDid(s)) { 12 + return null; 13 + } 14 + return s; 15 + } 16 + 17 + export const getDidDoc = async (did: DID) => { 18 + let url = `https://plc.directory/${did}`; 19 + if (did.startsWith('did:web')) { 20 + url = `https://${did.split(':')[2]}/.well-known/did.json`; 21 + } 22 + 23 + const response = await fetch(url); 24 + 25 + return PlcDocument.parse(await response.json()); 26 + }; 27 + 28 + export const getPdsUrl = async (did: DID) => { 29 + const plc = await getDidDoc(did); 30 + 31 + return ( 32 + plc.service.find((s) => s.type === "AtprotoPersonalDataServer") 33 + ?.serviceEndpoint ?? null 34 + ); 35 + }; 36 + 37 + const PlcDocument = z.object({ 38 + id: z.string(), 39 + alsoKnownAs: z.array(z.string()), 40 + service: z.array( 41 + z.object({ 42 + id: z.string(), 43 + type: z.string(), 44 + serviceEndpoint: z.string(), 45 + }), 46 + ), 47 + }); 48 + 49 + const DnsQueryResponse = z.object({ 50 + Answer: z.array( 51 + z.object({ 52 + name: z.string(), 53 + type: z.number(), 54 + TTL: z.number(), 55 + data: z.string(), 56 + }), 57 + ), 58 + }); 59 + 60 + async function getAtprotoDidFromDns(handle: string) { 61 + const url = new URL("https://cloudflare-dns.com/dns-query"); 62 + url.searchParams.set("type", "TXT"); 63 + url.searchParams.set("name", `_atproto.${handle}`); 64 + 65 + const response = await fetch(url, { 66 + headers: { 67 + Accept: "application/dns-json", 68 + }, 69 + }); 70 + 71 + const { Answer } = DnsQueryResponse.parse(await response.json()); 72 + // Answer[0].data is "\"did=...\"" (with quotes) 73 + const val = Answer[0]?.data 74 + ? JSON.parse(Answer[0]?.data).split("did=")[1] 75 + : null; 76 + 77 + return val ? parseDid(val) : null; 78 + } 79 + 80 + const getAtprotoFromHttps = async (handle: string) => { 81 + let res; 82 + const timeoutSignal = AbortSignal.timeout(1500); 83 + try { 84 + res = await fetch(`https://${handle}/.well-known/atproto-did`, { 85 + signal: timeoutSignal, 86 + }); 87 + } catch (_e) { 88 + // We're caching failures here, we should at some point invalidate the cache by listening to handle changes on the network 89 + return null; 90 + } 91 + 92 + if (!res.ok) { 93 + return null; 94 + } 95 + return parseDid((await res.text()).trim()); 96 + }; 97 + 98 + export const getVerifiedDid = async (handle: string) => { 99 + const [dnsDid, httpDid] = await Promise.all([ 100 + getAtprotoDidFromDns(handle).catch((_) => { 101 + return null; 102 + }), 103 + getAtprotoFromHttps(handle).catch(() => { 104 + return null; 105 + }), 106 + ]); 107 + 108 + if (dnsDid && httpDid && dnsDid !== httpDid) { 109 + return null; 110 + } 111 + 112 + const did = dnsDid ?? (httpDid ? parseDid(httpDid) : null); 113 + if (!did) { 114 + return null; 115 + } 116 + 117 + const plcDoc = await getDidDoc(did); 118 + const plcHandle = plcDoc.alsoKnownAs 119 + .find((handle) => handle.startsWith("at://")) 120 + ?.replace("at://", ""); 121 + 122 + if (!plcHandle) return null; 123 + 124 + return plcHandle.toLowerCase() === handle.toLowerCase() ? did : null; 125 + }; 126 + 127 + export const getDidFromHandleOrDid = async (handleOrDid: string) => { 128 + const decodedHandleOrDid = decodeURIComponent(handleOrDid); 129 + if (isDid(decodedHandleOrDid)) { 130 + return decodedHandleOrDid; 131 + } 132 + 133 + return getVerifiedDid(decodedHandleOrDid); 134 + }; 135 + 136 + export const getHandleFromHandleOrDid = async (did: string) => { 137 + const didDoc = await getDidDoc(did as DID); 138 + return didDoc.alsoKnownAs[0]?.substring(5); 139 + }
+1 -1
apps/web/src/routes/(app)/index.lazy.tsx
··· 59 59 <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4"> 60 60 <QueryPlaceholder query={query} cards cardsCount={12}> 61 61 {query.data?.data.recipes.map((v, idx) => ( 62 - <Link key={idx} href={`/recipes/${v.did}/${v.rkey}`}> 62 + <Link key={idx} href={`/recipes/${v.author}/${v.rkey}`}> 63 63 <Card> 64 64 <CardHeader> 65 65 <CardTitle>{v.title}</CardTitle>
+1 -1
apps/web/src/routes/(auth)/login.lazy.tsx
··· 1 - import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb'; 1 + import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; 2 2 import { Button } from '@/components/ui/button'; 3 3 import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; 4 4 import { Input } from '@/components/ui/input';
+2 -1
lexicons/moe/hayden/cookware/getRecipes.json
··· 37 37 }, 38 38 "result": { 39 39 "type": "object", 40 - "required": [ "rkey", "did", "title", "description", "ingredients", "steps"], 40 + "required": [ "author", "rkey", "did", "title", "ingredients", "steps"], 41 41 "properties": { 42 + "author": { "type": "string" }, 42 43 "rkey": { "type": "string" }, 43 44 "type": { "type": "string" }, 44 45 "did": { "type": "string" },
+2 -1
libs/lexicons/src/atcute.ts
··· 74 74 } 75 75 interface Result { 76 76 [Brand.Type]?: "moe.hayden.cookware.getRecipes#result"; 77 - description: string; 77 + author: string; 78 78 did: string; 79 79 ingredients: number; 80 80 rkey: string; 81 81 steps: number; 82 82 title: string; 83 + description?: string; 83 84 type?: string; 84 85 } 85 86 }
+3
pnpm-lock.yaml
··· 155 155 tailwindcss-animate: 156 156 specifier: ^1.0.7 157 157 version: 1.0.7(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.9.3)(@types/node@22.10.1)(typescript@5.6.3))) 158 + zod: 159 + specifier: ^3.23.8 160 + version: 3.23.8 158 161 devDependencies: 159 162 '@atcute/bluesky': 160 163 specifier: ^1.0.9