example restaurant review app on atproto
1import { OAuthResolverError } from "@atproto/oauth-client-node";
2import { serve } from "@hono/node-server";
3import { Hono } from "hono";
4import { generateSignedCookie, getSignedCookie } from "hono/cookie";
5import { client } from "./atproto/client";
6import { resolveMiniDoc } from "./utils";
7import "dotenv/config";
8import { jetstream } from "./ingester";
9import { Agent } from "@atproto/api";
10import { TID } from "@atproto/common";
11import { AppFooodzReview } from "./lexicons";
12import { is } from "@atcute/lexicons";
13import { Layout } from "./components";
14import { db } from "./drizzle/db";
15import { users } from "./drizzle/schema";
16import { eq } from "drizzle-orm";
17
18const app = new Hono();
19
20app.get("/client-metadata.json", (c) => {
21 return c.json(client.clientMetadata);
22});
23
24app.get("/oauth/callback", async (c) => {
25 const params = new URLSearchParams(c.req.url.split("?")[1]);
26
27 try {
28 const { session } = await client.callback(params);
29
30 // check if user is already added to db
31 const user = await db.query.users.findFirst({
32 where: eq(users.did, session.did),
33 });
34
35 if (!user) {
36 console.log("user not found in db, inserting");
37 await db.insert(users).values({
38 did: session.did,
39 });
40 }
41
42 const cookie = await generateSignedCookie(
43 "__foooodz_session",
44 session.did,
45 process.env.COOKIE_SECRET as string,
46 {
47 path: "/",
48 httpOnly: true,
49 sameSite: "lax",
50 maxAge: 60 * 60 * 24 * 7,
51 secure: process.env.NODE_ENV === "production",
52 },
53 );
54 return new Response(null, {
55 status: 302,
56 headers: {
57 "Set-Cookie": cookie,
58 Location: "/",
59 },
60 });
61 } catch (error) {
62 if (error instanceof Error) {
63 throw error;
64 }
65 }
66});
67
68app.post("/login", async (c) => {
69 try {
70 const formData = await c.req.formData();
71 const handle = formData.get("handle");
72 if (!handle || typeof handle !== "string") {
73 return c.json(
74 {
75 ok: false,
76 message: "Invalid handle",
77 },
78 400,
79 );
80 }
81
82 const { did } = await resolveMiniDoc(handle);
83
84 const ac = new AbortController();
85 const redirectUrl = await client.authorize(did, {
86 signal: ac.signal,
87 });
88 return c.redirect(redirectUrl.toString());
89 } catch (error) {
90 if (error instanceof OAuthResolverError) {
91 return c.json({ ok: false, message: error.message }, 500);
92 }
93 console.log(error);
94 return c.json({ ok: false, message: "something went wrong" }, 500);
95 }
96});
97
98app.get("/", async (c) => {
99 return c.html(
100 <Layout>
101 <form method="post" action="/login">
102 <label htmlFor="handle">Handle</label>
103 <input type="text" name="handle" id="handle" />
104 <button type="submit">Login</button>
105 </form>
106 </Layout>,
107 );
108});
109
110app.get("/review", async (c) => {
111 const reviews = await db.query.reviews.findMany({
112 limit: 10,
113 });
114 return c.html(
115 <Layout>
116 <form method="post" action="/review">
117 <label htmlFor="place">place</label>
118 <input type="text" name="place" id="place" maxlength={200} required />
119 <label htmlFor="review">review</label>
120 <textarea name="review" id="review" maxlength={3000} required />
121 <label htmlFor="rating">rating</label>
122 <select name="rating" id="rating" required>
123 {Array.from({ length: 5 }, (_, index) => (
124 <option key={index} value={index + 1}>
125 {index + 1}
126 </option>
127 ))}
128 </select>
129 <button type="submit">submit review</button>
130 </form>
131 <ul>
132 {reviews.map((review) => (
133 <li key={review.rkey}>
134 <article>
135 <h2>{review.place}</h2>
136 <p>{review.review}</p>
137 <span>{review.rating}</span>
138 <time datetime={review.createdAt}>{review.createdAt}</time>
139 </article>
140 </li>
141 ))}
142 </ul>
143 </Layout>,
144 );
145});
146
147app.post("/review", async (c) => {
148 const cookie = await getSignedCookie(c, process.env.COOKIE_SECRET as string);
149 if (cookie.__foooodz_session) {
150 try {
151 const oauthSession = await client.restore(cookie.__foooodz_session);
152 const agent = new Agent(oauthSession);
153 const formData = await c.req.formData();
154 const place = formData.get("place") as string;
155 const review = formData.get("review") as string;
156 const rating = formData.get("rating") as string;
157
158 const rkey = TID.nextStr();
159
160 const record: AppFooodzReview.Main = {
161 $type: "app.fooodz.review",
162 place,
163 rating: +rating,
164 review,
165 createdAt: new Date().toISOString(),
166 };
167
168 if (!is(AppFooodzReview.mainSchema, record)) {
169 return c.json({ ok: false, message: "invalid review record" }, 400);
170 }
171
172 await agent.com.atproto.repo.putRecord({
173 repo: agent.assertDid,
174 rkey,
175 record,
176 collection: "app.fooodz.review",
177 validate: false,
178 });
179
180 return c.redirect("/review");
181 } catch (error) {
182 return c.json({ ok: false, message: "failed to write record" }, 500);
183 }
184 }
185});
186
187serve(
188 {
189 fetch: app.fetch,
190 port: 3000,
191 },
192 (info) => {
193 console.log(`Server is running @ http://localhost:${info.port}`);
194 jetstream.start();
195 },
196);