A decentralized music tracking and discovery platform built on AT Protocol 馃幍
at feat/like-scrobble 269 lines 7.0 kB view raw
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;