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