A decentralized music tracking and discovery platform built on AT Protocol 馃幍
at feat/scrobble-user-avatar 572 lines 15 kB view raw
1import { equals } from "@xata.io/client"; 2import { ctx } from "context"; 3import crypto, { createHash } from "crypto"; 4import { Hono } from "hono"; 5import jwt from "jsonwebtoken"; 6import { decrypt, encrypt } from "lib/crypto"; 7import { env } from "lib/env"; 8import { requestCounter } from "metrics"; 9import { rateLimiter } from "ratelimiter"; 10import { emailSchema } from "types/email"; 11 12const app = new Hono(); 13 14app.use( 15 "/currently-playing", 16 rateLimiter({ 17 limit: 10, // max Spotify API calls 18 window: 15, // per 10 seconds 19 keyPrefix: "spotify-ratelimit", 20 }), 21); 22 23app.get("/login", async (c) => { 24 requestCounter.add(1, { method: "GET", route: "/spotify/login" }); 25 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 26 27 if (!bearer || bearer === "null") { 28 c.status(401); 29 return c.text("Unauthorized"); 30 } 31 32 const { did } = jwt.verify(bearer, env.JWT_SECRET, { 33 ignoreExpiration: true, 34 }); 35 36 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 37 if (!user) { 38 c.status(401); 39 return c.text("Unauthorized"); 40 } 41 42 const state = crypto.randomBytes(16).toString("hex"); 43 ctx.kv.set(state, did); 44 const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${env.SPOTIFY_CLIENT_ID}&response_type=code&redirect_uri=${env.SPOTIFY_REDIRECT_URI}&scope=user-read-private%20user-read-email%20user-read-playback-state%20user-read-currently-playing%20user-modify-playback-state%20playlist-modify-public%20playlist-modify-private%20playlist-read-private%20playlist-read-collaborative&state=${state}`; 45 c.header( 46 "Set-Cookie", 47 `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure`, 48 ); 49 return c.json({ redirectUrl }); 50}); 51 52app.get("/callback", async (c) => { 53 requestCounter.add(1, { method: "GET", route: "/spotify/callback" }); 54 const params = new URLSearchParams(c.req.url.split("?")[1]); 55 const { code, state } = Object.fromEntries(params.entries()); 56 57 const response = await fetch("https://accounts.spotify.com/api/token", { 58 method: "POST", 59 headers: { 60 "Content-Type": "application/x-www-form-urlencoded", 61 }, 62 body: new URLSearchParams({ 63 grant_type: "authorization_code", 64 code, 65 redirect_uri: env.SPOTIFY_REDIRECT_URI, 66 client_id: env.SPOTIFY_CLIENT_ID, 67 client_secret: env.SPOTIFY_CLIENT_SECRET, 68 }), 69 }); 70 const { access_token, refresh_token } = await response.json(); 71 72 if (!state) { 73 return c.redirect(env.FRONTEND_URL); 74 } 75 76 const did = ctx.kv.get(state); 77 if (!did) { 78 return c.redirect(env.FRONTEND_URL); 79 } 80 81 ctx.kv.delete(state); 82 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 83 84 if (!user) { 85 return c.redirect(env.FRONTEND_URL); 86 } 87 88 const spotifyToken = await ctx.client.db.spotify_tokens 89 .filter("user_id", equals(user.xata_id)) 90 .getFirst(); 91 92 await ctx.client.db.spotify_tokens.createOrUpdate(spotifyToken?.xata_id, { 93 user_id: user.xata_id, 94 access_token: encrypt(access_token, env.SPOTIFY_ENCRYPTION_KEY), 95 refresh_token: encrypt(refresh_token, env.SPOTIFY_ENCRYPTION_KEY), 96 }); 97 98 const spotifyUser = await ctx.client.db.spotify_accounts 99 .filter("user_id", equals(user.xata_id)) 100 .filter("is_beta_user", equals(true)) 101 .getFirst(); 102 103 if (spotifyUser?.email) { 104 ctx.nc.publish("rocksky.spotify.user", Buffer.from(spotifyUser.email)); 105 } 106 107 return c.redirect(env.FRONTEND_URL); 108}); 109 110app.post("/join", async (c) => { 111 requestCounter.add(1, { method: "POST", route: "/spotify/join" }); 112 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 113 114 if (!bearer || bearer === "null") { 115 c.status(401); 116 return c.text("Unauthorized"); 117 } 118 119 const { did } = jwt.verify(bearer, env.JWT_SECRET, { 120 ignoreExpiration: true, 121 }); 122 123 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 124 if (!user) { 125 c.status(401); 126 return c.text("Unauthorized"); 127 } 128 129 const body = await c.req.json(); 130 const parsed = emailSchema.safeParse(body); 131 132 if (parsed.error) { 133 c.status(400); 134 return c.text("Invalid email: " + parsed.error.message); 135 } 136 137 const { email } = parsed.data; 138 139 try { 140 await ctx.client.db.spotify_accounts.create({ 141 user_id: user.xata_id, 142 email, 143 is_beta_user: false, 144 }); 145 } catch (e) { 146 if ( 147 !e.message.includes("invalid record: column [user_id]: is not unique") 148 ) { 149 console.error(e.message); 150 } else { 151 throw e; 152 } 153 } 154 155 await fetch("https://beta.rocksky.app", { 156 method: "POST", 157 headers: { 158 "Content-Type": "application/json", 159 Authorization: `Bearer ${env.ROCKSKY_BETA_TOKEN}`, 160 }, 161 body: JSON.stringify({ email }), 162 }); 163 164 return c.json({ status: "ok" }); 165}); 166 167app.get("/currently-playing", async (c) => { 168 requestCounter.add(1, { method: "GET", route: "/spotify/currently-playing" }); 169 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 170 171 const payload = 172 bearer && bearer !== "null" 173 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 174 : {}; 175 const did = c.req.query("did") || payload.did; 176 177 if (!did) { 178 c.status(401); 179 return c.text("Unauthorized"); 180 } 181 182 const user = await ctx.client.db.users 183 .filter({ 184 $any: [{ did }, { handle: did }], 185 }) 186 .getFirst(); 187 188 if (!user) { 189 c.status(401); 190 return c.text("Unauthorized"); 191 } 192 193 const spotifyAccount = await ctx.client.db.spotify_accounts 194 .filter({ 195 $any: [{ "user_id.did": did }, { "user_id.handle": did }], 196 }) 197 .getFirst(); 198 199 if (!spotifyAccount) { 200 c.status(401); 201 return c.text("Unauthorized"); 202 } 203 204 const cached = await ctx.redis.get(`${spotifyAccount.email}:current`); 205 if (!cached) { 206 return c.json({}); 207 } 208 209 const track = JSON.parse(cached); 210 211 const sha256 = createHash("sha256") 212 .update( 213 `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase(), 214 ) 215 .digest("hex"); 216 217 const [result, liked] = await Promise.all([ 218 ctx.client.db.tracks.filter("sha256", equals(sha256)).getFirst(), 219 ctx.client.db.loved_tracks 220 .filter("user_id", equals(user.xata_id)) 221 .filter("track_id.sha256", equals(sha256)) 222 .getFirst(), 223 ]); 224 225 return c.json({ 226 ...track, 227 songUri: result?.uri, 228 artistUri: result?.artist_uri, 229 albumUri: result?.album_uri, 230 liked: !!liked, 231 sha256, 232 }); 233}); 234 235app.put("/pause", async (c) => { 236 requestCounter.add(1, { method: "PUT", route: "/spotify/pause" }); 237 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 238 239 const { did } = 240 bearer && bearer !== "null" 241 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 242 : {}; 243 244 if (!did) { 245 c.status(401); 246 return c.text("Unauthorized"); 247 } 248 249 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 250 251 if (!user) { 252 c.status(401); 253 return c.text("Unauthorized"); 254 } 255 256 const spotifyToken = await ctx.client.db.spotify_tokens 257 .filter("user_id", equals(user.xata_id)) 258 .getFirst(); 259 260 if (!spotifyToken) { 261 c.status(401); 262 return c.text("Unauthorized"); 263 } 264 265 const refreshToken = decrypt( 266 spotifyToken.refresh_token, 267 env.SPOTIFY_ENCRYPTION_KEY, 268 ); 269 270 // get new access token 271 const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 272 method: "POST", 273 headers: { 274 "Content-Type": "application/x-www-form-urlencoded", 275 }, 276 body: new URLSearchParams({ 277 grant_type: "refresh_token", 278 refresh_token: refreshToken, 279 client_id: env.SPOTIFY_CLIENT_ID, 280 client_secret: env.SPOTIFY_CLIENT_SECRET, 281 }), 282 }); 283 284 const { access_token } = await newAccessToken.json(); 285 286 const response = await fetch("https://api.spotify.com/v1/me/player/pause", { 287 method: "PUT", 288 headers: { 289 Authorization: `Bearer ${access_token}`, 290 }, 291 }); 292 293 if (response.status === 403) { 294 c.status(403); 295 return c.text(await response.text()); 296 } 297 298 return c.json(await response.json()); 299}); 300 301app.put("/play", async (c) => { 302 requestCounter.add(1, { method: "PUT", route: "/spotify/play" }); 303 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 304 305 const { did } = 306 bearer && bearer !== "null" 307 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 308 : {}; 309 310 if (!did) { 311 c.status(401); 312 return c.text("Unauthorized"); 313 } 314 315 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 316 317 if (!user) { 318 c.status(401); 319 return c.text("Unauthorized"); 320 } 321 322 const spotifyToken = await ctx.client.db.spotify_tokens 323 .filter("user_id", equals(user.xata_id)) 324 .getFirst(); 325 326 if (!spotifyToken) { 327 c.status(401); 328 return c.text("Unauthorized"); 329 } 330 331 const refreshToken = decrypt( 332 spotifyToken.refresh_token, 333 env.SPOTIFY_ENCRYPTION_KEY, 334 ); 335 336 // get new access token 337 const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 338 method: "POST", 339 headers: { 340 "Content-Type": "application/x-www-form-urlencoded", 341 }, 342 body: new URLSearchParams({ 343 grant_type: "refresh_token", 344 refresh_token: refreshToken, 345 client_id: env.SPOTIFY_CLIENT_ID, 346 client_secret: env.SPOTIFY_CLIENT_SECRET, 347 }), 348 }); 349 350 const { access_token } = await newAccessToken.json(); 351 352 const response = await fetch("https://api.spotify.com/v1/me/player/play", { 353 method: "PUT", 354 headers: { 355 Authorization: `Bearer ${access_token}`, 356 }, 357 }); 358 359 if (response.status === 403) { 360 c.status(403); 361 return c.text(await response.text()); 362 } 363 364 return c.json(await response.json()); 365}); 366 367app.post("/next", async (c) => { 368 requestCounter.add(1, { method: "POST", route: "/spotify/next" }); 369 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 370 371 const { did } = 372 bearer && bearer !== "null" 373 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 374 : {}; 375 376 if (!did) { 377 c.status(401); 378 return c.text("Unauthorized"); 379 } 380 381 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 382 383 if (!user) { 384 c.status(401); 385 return c.text("Unauthorized"); 386 } 387 388 const spotifyToken = await ctx.client.db.spotify_tokens 389 .filter("user_id", equals(user.xata_id)) 390 .getFirst(); 391 392 if (!spotifyToken) { 393 c.status(401); 394 return c.text("Unauthorized"); 395 } 396 397 const refreshToken = decrypt( 398 spotifyToken.refresh_token, 399 env.SPOTIFY_ENCRYPTION_KEY, 400 ); 401 402 // get new access token 403 const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 404 method: "POST", 405 headers: { 406 "Content-Type": "application/x-www-form-urlencoded", 407 }, 408 body: new URLSearchParams({ 409 grant_type: "refresh_token", 410 refresh_token: refreshToken, 411 client_id: env.SPOTIFY_CLIENT_ID, 412 client_secret: env.SPOTIFY_CLIENT_SECRET, 413 }), 414 }); 415 416 const { access_token } = await newAccessToken.json(); 417 418 const response = await fetch("https://api.spotify.com/v1/me/player/next", { 419 method: "POST", 420 headers: { 421 Authorization: `Bearer ${access_token}`, 422 }, 423 }); 424 425 if (response.status === 403) { 426 c.status(403); 427 return c.text(await response.text()); 428 } 429 430 return c.json(await response.json()); 431}); 432 433app.post("/previous", async (c) => { 434 requestCounter.add(1, { method: "POST", route: "/spotify/previous" }); 435 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 436 437 const { did } = 438 bearer && bearer !== "null" 439 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 440 : {}; 441 442 if (!did) { 443 c.status(401); 444 return c.text("Unauthorized"); 445 } 446 447 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 448 449 if (!user) { 450 c.status(401); 451 return c.text("Unauthorized"); 452 } 453 454 const spotifyToken = await ctx.client.db.spotify_tokens 455 .filter("user_id", equals(user.xata_id)) 456 .getFirst(); 457 458 if (!spotifyToken) { 459 c.status(401); 460 return c.text("Unauthorized"); 461 } 462 463 const refreshToken = decrypt( 464 spotifyToken.refresh_token, 465 env.SPOTIFY_ENCRYPTION_KEY, 466 ); 467 468 // get new access token 469 const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 470 method: "POST", 471 headers: { 472 "Content-Type": "application/x-www-form-urlencoded", 473 }, 474 body: new URLSearchParams({ 475 grant_type: "refresh_token", 476 refresh_token: refreshToken, 477 client_id: env.SPOTIFY_CLIENT_ID, 478 client_secret: env.SPOTIFY_CLIENT_SECRET, 479 }), 480 }); 481 482 const { access_token } = await newAccessToken.json(); 483 484 const response = await fetch( 485 "https://api.spotify.com/v1/me/player/previous", 486 { 487 method: "POST", 488 headers: { 489 Authorization: `Bearer ${access_token}`, 490 }, 491 }, 492 ); 493 494 if (response.status === 403) { 495 c.status(403); 496 return c.text(await response.text()); 497 } 498 499 return c.json(await response.json()); 500}); 501 502app.put("/seek", async (c) => { 503 requestCounter.add(1, { method: "PUT", route: "/spotify/seek" }); 504 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 505 506 const { did } = 507 bearer && bearer !== "null" 508 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 509 : {}; 510 511 if (!did) { 512 c.status(401); 513 return c.text("Unauthorized"); 514 } 515 516 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst(); 517 518 if (!user) { 519 c.status(401); 520 return c.text("Unauthorized"); 521 } 522 523 const spotifyToken = await ctx.client.db.spotify_tokens 524 .filter("user_id", equals(user.xata_id)) 525 .getFirst(); 526 527 if (!spotifyToken) { 528 c.status(401); 529 return c.text("Unauthorized"); 530 } 531 532 const refreshToken = decrypt( 533 spotifyToken.refresh_token, 534 env.SPOTIFY_ENCRYPTION_KEY, 535 ); 536 537 // get new access token 538 const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 539 method: "POST", 540 headers: { 541 "Content-Type": "application/x-www-form-urlencoded", 542 }, 543 body: new URLSearchParams({ 544 grant_type: "refresh_token", 545 refresh_token: refreshToken, 546 client_id: env.SPOTIFY_CLIENT_ID, 547 client_secret: env.SPOTIFY_CLIENT_SECRET, 548 }), 549 }); 550 551 const { access_token } = await newAccessToken.json(); 552 553 const position = c.req.query("position_ms"); 554 const response = await fetch( 555 `https://api.spotify.com/v1/me/player/seek?position_ms=${position}`, 556 { 557 method: "PUT", 558 headers: { 559 Authorization: `Bearer ${access_token}`, 560 }, 561 }, 562 ); 563 564 if (response.status === 403) { 565 c.status(403); 566 return c.text(await response.text()); 567 } 568 569 return c.json(await response.json()); 570}); 571 572export default app;