A decentralized music tracking and discovery platform built on AT Protocol 馃幍
at main 362 lines 9.4 kB view raw
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;