forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
1import { AtpAgent } from "@atproto/api";
2import { consola } from "consola";
3import type { BlobRef } from "@atproto/lexicon";
4import { isValidHandle } from "@atproto/syntax";
5import { ctx } from "context";
6import { and, desc, eq } from "drizzle-orm";
7import { Hono } from "hono";
8import jwt from "jsonwebtoken";
9import * as Profile from "lexicon/types/app/bsky/actor/profile";
10import { deepSnakeCaseKeys } from "lib";
11import { createAgent } from "lib/agent";
12import { env } from "lib/env";
13import extractPdsFromDid from "lib/extractPdsFromDid";
14import { requestCounter } from "metrics";
15import dropboxAccounts from "schema/dropbox-accounts";
16import googleDriveAccounts from "schema/google-drive-accounts";
17import spotifyAccounts from "schema/spotify-accounts";
18import spotifyTokens from "schema/spotify-tokens";
19import users from "schema/users";
20import { SCOPES } from "auth/client";
21
22const app = new Hono();
23
24app.get("/login", async (c) => {
25 requestCounter.add(1, { method: "GET", route: "/login" });
26 const { handle, cli, prompt } = c.req.query();
27 if ((typeof handle !== "string" || !isValidHandle(handle)) && !prompt) {
28 c.status(400);
29 return c.text("Invalid handle");
30 }
31 try {
32 const url = await ctx.oauthClient.authorize(
33 prompt ? "tsiry.selfhosted.social" : handle,
34 {
35 scope: SCOPES.join(" "),
36 // @ts-expect-error: allow custom prompt param
37 prompt,
38 },
39 );
40 if (cli) {
41 ctx.kv.set(`cli:${handle}`, "1");
42 }
43 return c.redirect(url.toString());
44 } catch (e) {
45 c.status(500);
46 return c.text(e.toString());
47 }
48});
49
50app.post("/login", async (c) => {
51 requestCounter.add(1, { method: "POST", route: "/login" });
52 const { handle, cli, password } = await c.req.json();
53 if (typeof handle !== "string" || !isValidHandle(handle)) {
54 c.status(400);
55 return c.text("Invalid handle");
56 }
57
58 try {
59 if (password) {
60 const defaultAgent = new AtpAgent({
61 service: new URL("https://bsky.social"),
62 });
63 const {
64 data: { did },
65 } = await defaultAgent.resolveHandle({ handle });
66
67 let pds = await ctx.redis.get(`pds:${did}`);
68 if (!pds) {
69 pds = await extractPdsFromDid(did);
70 await ctx.redis.setEx(`pds:${did}`, 60 * 15, pds);
71 }
72
73 const agent = new AtpAgent({
74 service: new URL(pds),
75 });
76
77 await agent.login({
78 identifier: handle,
79 password,
80 });
81
82 await ctx.sqliteDb
83 .insertInto("auth_session")
84 .values({
85 key: `atp:${did}`,
86 session: JSON.stringify(agent.session),
87 })
88 .onConflict((oc) =>
89 oc
90 .column("key")
91 .doUpdateSet({ session: JSON.stringify(agent.session) }),
92 )
93 .execute();
94
95 const token = jwt.sign(
96 {
97 did,
98 exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7,
99 },
100 env.JWT_SECRET,
101 );
102
103 return c.text(`jwt:${token}`);
104 }
105
106 const url = await ctx.oauthClient.authorize(handle, {
107 scope: SCOPES.join(" "),
108 });
109
110 if (cli) {
111 ctx.kv.set(`cli:${handle}`, "1");
112 }
113
114 return c.text(url.toString());
115 } catch (e) {
116 c.status(500);
117 return c.text(e.toString());
118 }
119});
120
121app.get("/oauth/callback", async (c) => {
122 requestCounter.add(1, { method: "GET", route: "/oauth/callback" });
123 const params = new URLSearchParams(c.req.url.split("?")[1]);
124 let did: string, cli: string;
125
126 try {
127 const { session } = await ctx.oauthClient.callback(params);
128 did = session.did;
129 const handle = await ctx.resolver.resolveDidToHandle(did);
130 cli = ctx.kv.get(`cli:${handle}`);
131 ctx.kv.delete(`cli:${handle}`);
132
133 const token = jwt.sign(
134 {
135 did,
136 exp: cli
137 ? Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 1000
138 : Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7,
139 },
140 env.JWT_SECRET,
141 );
142 ctx.kv.set(did, token);
143 } catch (err) {
144 consola.error({ err }, "oauth callback failed");
145 return c.redirect(`${env.FRONTEND_URL}?error=1`);
146 }
147
148 const [spotifyUser] = await ctx.db
149 .select()
150 .from(spotifyAccounts)
151 .where(
152 and(
153 eq(spotifyAccounts.userId, did),
154 eq(spotifyAccounts.isBetaUser, true),
155 ),
156 )
157 .limit(1)
158 .execute();
159
160 if (spotifyUser?.email) {
161 ctx.nc.publish("rocksky.spotify.user", Buffer.from(spotifyUser.email));
162 }
163
164 if (!cli) {
165 return c.redirect(`${env.FRONTEND_URL}?did=${did}`);
166 }
167
168 return c.redirect(`${env.FRONTEND_URL}?did=${did}&cli=${cli}`);
169});
170
171app.get("/profile", async (c) => {
172 requestCounter.add(1, { method: "GET", route: "/profile" });
173 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
174
175 if (!bearer || bearer === "null") {
176 c.status(401);
177 return c.text("Unauthorized");
178 }
179
180 const { did } = jwt.verify(bearer, env.JWT_SECRET, {
181 ignoreExpiration: true,
182 });
183
184 const agent = await createAgent(ctx.oauthClient, did);
185
186 if (!agent) {
187 c.status(401);
188 return c.text("Unauthorized");
189 }
190
191 const { data: profileRecord } = await agent.com.atproto.repo.getRecord({
192 repo: agent.assertDid,
193 collection: "app.bsky.actor.profile",
194 rkey: "self",
195 });
196 const handle = await ctx.resolver.resolveDidToHandle(did);
197 const profile: { handle?: string; displayName?: string; avatar?: BlobRef } =
198 Profile.isRecord(profileRecord.value)
199 ? { ...profileRecord.value, handle }
200 : {};
201
202 if (profile.handle) {
203 try {
204 await ctx.db
205 .insert(users)
206 .values({
207 did,
208 handle,
209 displayName: profile.displayName,
210 avatar: `https://cdn.bsky.app/img/avatar/plain/${did}/${profile.avatar.ref.toString()}@jpeg`,
211 })
212 .execute();
213 } catch (e) {
214 if (!e.message.includes("invalid record: column [did]: is not unique")) {
215 consola.error(e.message);
216 } else {
217 await ctx.db
218 .update(users)
219 .set({
220 handle,
221 displayName: profile.displayName,
222 avatar: `https://cdn.bsky.app/img/avatar/plain/${did}/${profile.avatar.ref.toString()}@jpeg`,
223 })
224 .where(eq(users.did, did))
225 .execute();
226 }
227 }
228
229 const [user, lastUser] = await Promise.all([
230 ctx.db.select().from(users).where(eq(users.did, did)).limit(1).execute(),
231 ctx.db
232 .select()
233 .from(users)
234 .orderBy(desc(users.createdAt))
235 .limit(1)
236 .execute(),
237 ]);
238
239 ctx.nc.publish(
240 "rocksky.user",
241 Buffer.from(JSON.stringify(deepSnakeCaseKeys(user))),
242 );
243
244 await ctx.kv.set("lastUser", lastUser[0].id);
245 }
246
247 const [spotifyUser, spotifyToken, googledrive, dropbox] = await Promise.all([
248 ctx.db
249 .select()
250 .from(spotifyAccounts)
251 .where(
252 and(
253 eq(spotifyAccounts.userId, did),
254 eq(spotifyAccounts.isBetaUser, true),
255 ),
256 )
257 .limit(1)
258 .execute(),
259 ctx.db
260 .select()
261 .from(spotifyTokens)
262 .where(eq(spotifyTokens.userId, did))
263 .limit(1)
264 .execute(),
265 ctx.db
266 .select()
267 .from(googleDriveAccounts)
268 .where(
269 and(
270 eq(googleDriveAccounts.userId, did),
271 eq(googleDriveAccounts.isBetaUser, true),
272 ),
273 )
274 .limit(1)
275 .execute(),
276 ctx.db
277 .select()
278 .from(dropboxAccounts)
279 .where(
280 and(
281 eq(dropboxAccounts.userId, did),
282 eq(dropboxAccounts.isBetaUser, true),
283 ),
284 )
285 .limit(1)
286 .execute(),
287 ]).then(([s, t, g, d]) => deepSnakeCaseKeys([s[0], t[0], g[0], d[0]]));
288
289 return c.json({
290 ...profile,
291 spotifyUser,
292 spotifyConnected: !!spotifyToken,
293 googledrive,
294 dropbox,
295 did,
296 });
297});
298
299app.get("/client-metadata.json", async (c) => {
300 requestCounter.add(1, { method: "GET", route: "/client-metadata.json" });
301 return c.json(ctx.oauthClient.clientMetadata);
302});
303
304app.get("/token", async (c) => {
305 requestCounter.add(1, { method: "GET", route: "/token" });
306 const did = c.req.header("session-did");
307
308 if (typeof did !== "string" || !did || did === "null") {
309 c.status(401);
310 return c.text("Unauthorized");
311 }
312
313 const token = ctx.kv.get(did);
314
315 if (!token) {
316 c.status(401);
317 return c.text("Unauthorized");
318 }
319
320 ctx.kv.delete(did);
321
322 return c.json({ token });
323});
324
325app.get("/oauth-client-metadata.json", (c) =>
326 c.json(ctx.oauthClient.clientMetadata),
327);
328
329app.get("/jwks.json", (c) =>
330 c.json({
331 keys: [
332 {
333 kty: "EC",
334 use: "sig",
335 alg: "ES256",
336 kid: "2dfa3fd9-57b3-4738-ac27-9e6dadec13b7",
337 crv: "P-256",
338 x: "V_00KDnoEPsNqbt0y2Ke8v27Mv9WP70JylDUD5rvIek",
339 y: "HAyjaQeA2DU6wjZO0ggTadUS6ij1rmiYTxzmWeBKfRc",
340 },
341 {
342 kty: "EC",
343 use: "sig",
344 alg: "ES256",
345 kid: "5e816ff2-6bff-4177-b1c0-67ad3cd3e7cd",
346 crv: "P-256",
347 x: "YwEY5NsoYQVB_G7xPYMl9sUtxRbcPFNffnZcTS5nbPQ",
348 y: "5n5mybPvISyYAnRv1Ii1geqKfXv2GA8p9Xemwx2a8CM",
349 },
350 {
351 kty: "EC",
352 use: "sig",
353 kid: "a1067a48-a54a-43a0-9758-4d55b51fdd8b",
354 crv: "P-256",
355 x: "yq17Nd2DGcjP1i9I0NN3RBmgSbLQUZOtG6ec5GaqzmU",
356 y: "ieIU9mcfaZwAW5b3WgJkIRgddymG_ckcZ0n1XjbEIvc",
357 },
358 ],
359 }),
360);
361
362export default app;