forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
1import type { BlobRef } from "@atproto/lexicon";
2import { isValidHandle } from "@atproto/syntax";
3import { ctx } from "context";
4import { and, desc, eq } from "drizzle-orm";
5import { Hono } from "hono";
6import jwt from "jsonwebtoken";
7import * as Profile from "lexicon/types/app/bsky/actor/profile";
8import { deepSnakeCaseKeys } from "lib";
9import { createAgent } from "lib/agent";
10import { env } from "lib/env";
11import { requestCounter } from "metrics";
12import dropboxAccounts from "schema/dropbox-accounts";
13import googleDriveAccounts from "schema/google-drive-accounts";
14import spotifyAccounts from "schema/spotify-accounts";
15import spotifyTokens from "schema/spotify-tokens";
16import users from "schema/users";
17
18const app = new Hono();
19
20app.get("/login", async (c) => {
21 requestCounter.add(1, { method: "GET", route: "/login" });
22 const { handle, cli } = c.req.query();
23 if (typeof handle !== "string" || !isValidHandle(handle)) {
24 c.status(400);
25 return c.text("Invalid handle");
26 }
27 try {
28 const url = await ctx.oauthClient.authorize(handle, {
29 scope: "atproto transition:generic",
30 });
31 if (cli) {
32 ctx.kv.set(`cli:${handle}`, "1");
33 }
34 return c.redirect(url.toString());
35 } catch (e) {
36 c.status(500);
37 return c.text(e.toString());
38 }
39});
40
41app.post("/login", async (c) => {
42 requestCounter.add(1, { method: "POST", route: "/login" });
43 const { handle, cli } = await c.req.json();
44 if (typeof handle !== "string" || !isValidHandle(handle)) {
45 c.status(400);
46 return c.text("Invalid handle");
47 }
48
49 try {
50 const url = await ctx.oauthClient.authorize(handle, {
51 scope: "atproto transition:generic",
52 });
53
54 if (cli) {
55 ctx.kv.set(`cli:${handle}`, "1");
56 }
57
58 return c.text(url.toString());
59 } catch (e) {
60 c.status(500);
61 return c.text(e.toString());
62 }
63});
64
65app.get("/oauth/callback", async (c) => {
66 requestCounter.add(1, { method: "GET", route: "/oauth/callback" });
67 const params = new URLSearchParams(c.req.url.split("?")[1]);
68 let did: string, cli: string;
69
70 try {
71 const { session } = await ctx.oauthClient.callback(params);
72 did = session.did;
73 const handle = await ctx.resolver.resolveDidToHandle(did);
74 cli = ctx.kv.get(`cli:${handle}`);
75 ctx.kv.delete(`cli:${handle}`);
76
77 const token = jwt.sign(
78 {
79 did,
80 exp: cli
81 ? Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 1000
82 : Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7,
83 },
84 env.JWT_SECRET,
85 );
86 ctx.kv.set(did, token);
87 } catch (err) {
88 console.error({ err }, "oauth callback failed");
89 return c.redirect(`${env.FRONTEND_URL}?error=1`);
90 }
91
92 const [spotifyUser] = await ctx.db
93 .select()
94 .from(spotifyAccounts)
95 .where(
96 and(
97 eq(spotifyAccounts.userId, did),
98 eq(spotifyAccounts.isBetaUser, true),
99 ),
100 )
101 .limit(1)
102 .execute();
103
104 if (spotifyUser?.email) {
105 ctx.nc.publish("rocksky.spotify.user", Buffer.from(spotifyUser.email));
106 }
107
108 if (!cli) {
109 return c.redirect(`${env.FRONTEND_URL}?did=${did}`);
110 }
111
112 return c.redirect(`${env.FRONTEND_URL}?did=${did}&cli=${cli}`);
113});
114
115app.get("/profile", async (c) => {
116 requestCounter.add(1, { method: "GET", route: "/profile" });
117 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
118
119 if (!bearer || bearer === "null") {
120 c.status(401);
121 return c.text("Unauthorized");
122 }
123
124 const { did } = jwt.verify(bearer, env.JWT_SECRET, {
125 ignoreExpiration: true,
126 });
127
128 const agent = await createAgent(ctx.oauthClient, did);
129
130 if (!agent) {
131 c.status(401);
132 return c.text("Unauthorized");
133 }
134
135 const { data: profileRecord } = await agent.com.atproto.repo.getRecord({
136 repo: agent.assertDid,
137 collection: "app.bsky.actor.profile",
138 rkey: "self",
139 });
140 const handle = await ctx.resolver.resolveDidToHandle(did);
141 const profile: { handle?: string; displayName?: string; avatar?: BlobRef } =
142 Profile.isRecord(profileRecord.value)
143 ? { ...profileRecord.value, handle }
144 : {};
145
146 if (profile.handle) {
147 try {
148 await ctx.db
149 .insert(users)
150 .values({
151 did,
152 handle,
153 displayName: profile.displayName,
154 avatar: `https://cdn.bsky.app/img/avatar/plain/${did}/${profile.avatar.ref.toString()}@jpeg`,
155 })
156 .execute();
157 } catch (e) {
158 if (!e.message.includes("invalid record: column [did]: is not unique")) {
159 console.error(e.message);
160 } else {
161 await ctx.db
162 .update(users)
163 .set({
164 handle,
165 displayName: profile.displayName,
166 avatar: `https://cdn.bsky.app/img/avatar/plain/${did}/${profile.avatar.ref.toString()}@jpeg`,
167 })
168 .where(eq(users.did, did))
169 .execute();
170 }
171 }
172
173 const [user, lastUser] = await Promise.all([
174 ctx.db.select().from(users).where(eq(users.did, did)).limit(1).execute(),
175 ctx.db
176 .select()
177 .from(users)
178 .orderBy(desc(users.createdAt))
179 .limit(1)
180 .execute(),
181 ]);
182
183 ctx.nc.publish(
184 "rocksky.user",
185 Buffer.from(JSON.stringify(deepSnakeCaseKeys(user))),
186 );
187
188 await ctx.kv.set("lastUser", lastUser[0].id);
189 }
190
191 const [spotifyUser, spotifyToken, googledrive, dropbox] = await Promise.all([
192 ctx.db
193 .select()
194 .from(spotifyAccounts)
195 .where(
196 and(
197 eq(spotifyAccounts.userId, did),
198 eq(spotifyAccounts.isBetaUser, true),
199 ),
200 )
201 .limit(1)
202 .execute(),
203 ctx.db
204 .select()
205 .from(spotifyTokens)
206 .where(eq(spotifyTokens.userId, did))
207 .limit(1)
208 .execute(),
209 ctx.db
210 .select()
211 .from(googleDriveAccounts)
212 .where(
213 and(
214 eq(googleDriveAccounts.userId, did),
215 eq(googleDriveAccounts.isBetaUser, true),
216 ),
217 )
218 .limit(1)
219 .execute(),
220 ctx.db
221 .select()
222 .from(dropboxAccounts)
223 .where(
224 and(
225 eq(dropboxAccounts.userId, did),
226 eq(dropboxAccounts.isBetaUser, true),
227 ),
228 )
229 .limit(1)
230 .execute(),
231 ]).then(([s, t, g, d]) => deepSnakeCaseKeys([s[0], t[0], g[0], d[0]]));
232
233 return c.json({
234 ...profile,
235 spotifyUser,
236 spotifyConnected: !!spotifyToken,
237 googledrive,
238 dropbox,
239 did,
240 });
241});
242
243app.get("/client-metadata.json", async (c) => {
244 requestCounter.add(1, { method: "GET", route: "/client-metadata.json" });
245 return c.json(ctx.oauthClient.clientMetadata);
246});
247
248app.get("/token", async (c) => {
249 requestCounter.add(1, { method: "GET", route: "/token" });
250 const did = c.req.header("session-did");
251
252 if (typeof did !== "string" || !did || did === "null") {
253 c.status(401);
254 return c.text("Unauthorized");
255 }
256
257 const token = ctx.kv.get(did);
258
259 if (!token) {
260 c.status(401);
261 return c.text("Unauthorized");
262 }
263
264 ctx.kv.delete(did);
265
266 return c.json({ token });
267});
268
269export default app;