A decentralized music tracking and discovery platform built on AT Protocol 馃幍
at main 794 lines 20 kB view raw
1import { consola } from "consola"; 2import { ctx } from "context"; 3import { and, eq, or, sql } from "drizzle-orm"; 4import { Hono } from "hono"; 5import jwt from "jsonwebtoken"; 6import { decrypt, encrypt } from "lib/crypto"; 7import { env } from "lib/env"; 8import _ from "lodash"; 9import { requestCounter } from "metrics"; 10import crypto, { createHash } from "node:crypto"; 11import { rateLimiter } from "ratelimiter"; 12import lovedTracks from "schema/loved-tracks"; 13import spotifyAccounts from "schema/spotify-accounts"; 14import spotifyApps from "schema/spotify-apps"; 15import spotifyTokens from "schema/spotify-tokens"; 16import tracks from "schema/tracks"; 17import users from "schema/users"; 18import { emailSchema } from "types/email"; 19 20const app = new Hono(); 21 22app.use( 23 "/currently-playing", 24 rateLimiter({ 25 limit: 10, // max Spotify API calls 26 window: 15, // per 10 seconds 27 keyPrefix: "spotify-ratelimit", 28 }), 29); 30 31app.get("/login", async (c) => { 32 requestCounter.add(1, { method: "GET", route: "/spotify/login" }); 33 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 34 35 if (!bearer || bearer === "null") { 36 c.status(401); 37 return c.text("Unauthorized"); 38 } 39 40 const { did } = jwt.verify(bearer, env.JWT_SECRET, { 41 ignoreExpiration: true, 42 }); 43 44 const user = await ctx.db 45 .select() 46 .from(users) 47 .where(eq(users.did, did)) 48 .limit(1) 49 .then((rows) => rows[0]); 50 51 if (!user) { 52 c.status(401); 53 return c.text("Unauthorized"); 54 } 55 56 const spotifyAccount = await ctx.db 57 .select() 58 .from(spotifyAccounts) 59 .leftJoin(users, eq(spotifyAccounts.userId, users.id)) 60 .leftJoin( 61 spotifyApps, 62 eq(spotifyAccounts.spotifyAppId, spotifyApps.spotifyAppId), 63 ) 64 .where( 65 and( 66 eq(spotifyAccounts.userId, user.id), 67 eq(spotifyAccounts.isBetaUser, true), 68 ), 69 ) 70 .limit(1) 71 .then((rows) => rows[0]); 72 73 const state = crypto.randomBytes(16).toString("hex"); 74 ctx.kv.set(state, did); 75 const scopes = [ 76 "user-read-private", 77 "user-read-email", 78 "user-read-playback-state", 79 "user-read-currently-playing", 80 "user-modify-playback-state", 81 "playlist-modify-public", 82 "playlist-modify-private", 83 "playlist-read-private", 84 "playlist-read-collaborative", 85 ]; 86 const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${spotifyAccount?.spotify_apps?.spotifyAppId}&response_type=code&redirect_uri=${env.SPOTIFY_REDIRECT_URI}&scope=${scopes.join("%20")}&state=${state}`; 87 c.header( 88 "Set-Cookie", 89 `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure`, 90 ); 91 return c.json({ redirectUrl }); 92}); 93 94app.get("/callback", async (c) => { 95 requestCounter.add(1, { method: "GET", route: "/spotify/callback" }); 96 const params = new URLSearchParams(c.req.url.split("?")[1]); 97 const { code, state } = Object.fromEntries(params.entries()); 98 99 if (!state) { 100 return c.redirect(env.FRONTEND_URL); 101 } 102 103 const did = ctx.kv.get(state); 104 if (!did) { 105 return c.redirect(env.FRONTEND_URL); 106 } 107 108 ctx.kv.delete(state); 109 const user = await ctx.db 110 .select() 111 .from(users) 112 .where(eq(users.did, did)) 113 .limit(1) 114 .then((rows) => rows[0]); 115 116 if (!user) { 117 return c.redirect(env.FRONTEND_URL); 118 } 119 120 const spotifyAccount = await ctx.db 121 .select() 122 .from(spotifyAccounts) 123 .leftJoin( 124 spotifyApps, 125 eq(spotifyAccounts.spotifyAppId, spotifyApps.spotifyAppId), 126 ) 127 .where( 128 and( 129 eq(spotifyAccounts.userId, user.id), 130 eq(spotifyAccounts.isBetaUser, true), 131 ), 132 ) 133 .limit(1) 134 .then((rows) => rows[0]); 135 136 const spotifyAppId = spotifyAccount.spotify_accounts.spotifyAppId 137 ? spotifyAccount.spotify_accounts.spotifyAppId 138 : env.SPOTIFY_CLIENT_ID; 139 const spotifySecret = spotifyAccount.spotify_apps.spotifySecret 140 ? spotifyAccount.spotify_apps.spotifySecret 141 : env.SPOTIFY_CLIENT_SECRET; 142 143 const response = await fetch("https://accounts.spotify.com/api/token", { 144 method: "POST", 145 headers: { 146 "Content-Type": "application/x-www-form-urlencoded", 147 }, 148 body: new URLSearchParams({ 149 grant_type: "authorization_code", 150 code, 151 redirect_uri: env.SPOTIFY_REDIRECT_URI, 152 client_id: spotifyAppId, 153 client_secret: decrypt(spotifySecret, env.SPOTIFY_ENCRYPTION_KEY), 154 }), 155 }); 156 const { 157 access_token, 158 refresh_token, 159 }: { 160 access_token: string; 161 refresh_token: string; 162 } = await response.json(); 163 164 const existingSpotifyToken = await ctx.db 165 .select() 166 .from(spotifyTokens) 167 .where(eq(spotifyTokens.userId, user.id)) 168 .limit(1) 169 .then((rows) => rows[0]); 170 171 if (existingSpotifyToken) { 172 await ctx.db 173 .update(spotifyTokens) 174 .set({ 175 accessToken: encrypt(access_token, env.SPOTIFY_ENCRYPTION_KEY), 176 refreshToken: encrypt(refresh_token, env.SPOTIFY_ENCRYPTION_KEY), 177 }) 178 .where(eq(spotifyTokens.id, existingSpotifyToken.id)); 179 } else { 180 await ctx.db.insert(spotifyTokens).values({ 181 userId: user.id, 182 accessToken: encrypt(access_token, env.SPOTIFY_ENCRYPTION_KEY), 183 refreshToken: encrypt(refresh_token, env.SPOTIFY_ENCRYPTION_KEY), 184 spotifyAppId, 185 }); 186 } 187 188 const spotifyUser = await ctx.db 189 .select() 190 .from(spotifyAccounts) 191 .where( 192 and( 193 eq(spotifyAccounts.userId, user.id), 194 eq(spotifyAccounts.isBetaUser, true), 195 ), 196 ) 197 .limit(1) 198 .then((rows) => rows[0]); 199 200 if (spotifyUser?.email) { 201 ctx.nc.publish("rocksky.spotify.user", Buffer.from(spotifyUser.email)); 202 } 203 204 return c.redirect(env.FRONTEND_URL); 205}); 206 207app.post("/join", async (c) => { 208 requestCounter.add(1, { method: "POST", route: "/spotify/join" }); 209 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 210 211 if (!bearer || bearer === "null") { 212 c.status(401); 213 return c.text("Unauthorized"); 214 } 215 216 const { did } = jwt.verify(bearer, env.JWT_SECRET, { 217 ignoreExpiration: true, 218 }); 219 220 const user = await ctx.db 221 .select() 222 .from(users) 223 .where(eq(users.did, did)) 224 .limit(1) 225 .then((rows) => rows[0]); 226 227 if (!user) { 228 c.status(401); 229 return c.text("Unauthorized"); 230 } 231 232 const body = await c.req.json(); 233 const parsed = emailSchema.safeParse(body); 234 235 if (parsed.error) { 236 c.status(400); 237 return c.text(`Invalid email: ${parsed.error.message}`); 238 } 239 240 const apps = await ctx.db 241 .select({ 242 appId: spotifyApps.id, 243 spotifyAppId: spotifyApps.spotifyAppId, 244 accountCount: sql<number>`COUNT(${spotifyAccounts.id})`.as( 245 "account_count", 246 ), 247 }) 248 .from(spotifyApps) 249 .leftJoin( 250 spotifyAccounts, 251 eq(spotifyApps.spotifyAppId, spotifyAccounts.spotifyAppId), 252 ) 253 .groupBy(spotifyApps.id, spotifyApps.spotifyAppId) 254 .having(sql`COUNT(${spotifyAccounts.id}) < 25`); 255 256 const { email } = parsed.data; 257 258 try { 259 await ctx.db.insert(spotifyAccounts).values({ 260 userId: user.id, 261 email, 262 isBetaUser: false, 263 spotifyAppId: _.get(apps, "[0].spotifyAppId"), 264 }); 265 } catch (e) { 266 if (!e.message.includes("duplicate key value violates unique constraint")) { 267 consola.error(e.message); 268 } else { 269 throw e; 270 } 271 } 272 273 await fetch("https://beta.rocksky.app", { 274 method: "POST", 275 headers: { 276 "Content-Type": "application/json", 277 Authorization: `Bearer ${env.ROCKSKY_BETA_TOKEN}`, 278 }, 279 body: JSON.stringify({ email }), 280 }); 281 282 return c.json({ status: "ok" }); 283}); 284 285app.get("/currently-playing", async (c) => { 286 requestCounter.add(1, { method: "GET", route: "/spotify/currently-playing" }); 287 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 288 289 const payload = 290 bearer && bearer !== "null" 291 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 292 : {}; 293 const did = c.req.query("did") || payload.did; 294 295 if (!did) { 296 c.status(401); 297 return c.text("Unauthorized"); 298 } 299 300 const user = await ctx.db 301 .select() 302 .from(users) 303 .where(or(eq(users.did, did), eq(users.handle, did))) 304 .limit(1) 305 .then((rows) => rows[0]); 306 307 if (!user) { 308 c.status(401); 309 return c.text("Unauthorized"); 310 } 311 312 const spotifyAccount = await ctx.db 313 .select({ 314 spotifyAccount: spotifyAccounts, 315 user: users, 316 }) 317 .from(spotifyAccounts) 318 .innerJoin(users, eq(spotifyAccounts.userId, users.id)) 319 .where(or(eq(users.did, did), eq(users.handle, did))) 320 .limit(1) 321 .then((rows) => rows[0]); 322 323 if (!spotifyAccount) { 324 c.status(401); 325 return c.text("Unauthorized"); 326 } 327 328 const cached = await ctx.redis.get( 329 `${spotifyAccount.spotifyAccount.email}:current`, 330 ); 331 if (!cached) { 332 return c.json({}); 333 } 334 335 const track = JSON.parse(cached); 336 337 const sha256 = createHash("sha256") 338 .update( 339 `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase(), 340 ) 341 .digest("hex"); 342 343 const [result, liked] = await Promise.all([ 344 ctx.db 345 .select() 346 .from(tracks) 347 .where(eq(tracks.sha256, sha256)) 348 .limit(1) 349 .then((rows) => rows[0]), 350 ctx.db 351 .select({ 352 lovedTrack: lovedTracks, 353 track: tracks, 354 }) 355 .from(lovedTracks) 356 .innerJoin(tracks, eq(lovedTracks.trackId, tracks.id)) 357 .where(and(eq(lovedTracks.userId, user.id), eq(tracks.sha256, sha256))) 358 .limit(1) 359 .then((rows) => rows[0]), 360 ]); 361 362 return c.json({ 363 ...track, 364 songUri: result?.uri, 365 artistUri: result?.artistUri, 366 albumUri: result?.albumUri, 367 liked: !!liked, 368 sha256, 369 }); 370}); 371 372app.put("/pause", async (c) => { 373 requestCounter.add(1, { method: "PUT", route: "/spotify/pause" }); 374 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 375 376 const { did } = 377 bearer && bearer !== "null" 378 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 379 : {}; 380 381 if (!did) { 382 c.status(401); 383 return c.text("Unauthorized"); 384 } 385 386 const user = await ctx.db 387 .select() 388 .from(users) 389 .where(eq(users.did, did)) 390 .limit(1) 391 .then((rows) => rows[0]); 392 393 if (!user) { 394 c.status(401); 395 return c.text("Unauthorized"); 396 } 397 398 const spotifyToken = await ctx.db 399 .select() 400 .from(spotifyTokens) 401 .leftJoin( 402 spotifyApps, 403 eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), 404 ) 405 .where(eq(spotifyTokens.userId, user.id)) 406 .limit(1) 407 .then((rows) => rows[0]); 408 409 if (!spotifyToken) { 410 c.status(401); 411 return c.text("Unauthorized"); 412 } 413 414 const refreshToken = decrypt( 415 spotifyToken.spotify_tokens.refreshToken, 416 env.SPOTIFY_ENCRYPTION_KEY, 417 ); 418 419 // get new access token 420 const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 421 method: "POST", 422 headers: { 423 "Content-Type": "application/x-www-form-urlencoded", 424 }, 425 body: new URLSearchParams({ 426 grant_type: "refresh_token", 427 refresh_token: refreshToken, 428 client_id: spotifyToken.spotify_apps.spotifyAppId, 429 client_secret: decrypt( 430 spotifyToken.spotify_apps.spotifySecret, 431 env.SPOTIFY_ENCRYPTION_KEY, 432 ), 433 }), 434 }); 435 436 const { access_token } = (await newAccessToken.json()) as { 437 access_token: string; 438 }; 439 440 const response = await fetch("https://api.spotify.com/v1/me/player/pause", { 441 method: "PUT", 442 headers: { 443 Authorization: `Bearer ${access_token}`, 444 }, 445 }); 446 447 if (response.status === 403) { 448 c.status(403); 449 return c.text(await response.text()); 450 } 451 452 return c.json(await response.json()); 453}); 454 455app.put("/play", async (c) => { 456 requestCounter.add(1, { method: "PUT", route: "/spotify/play" }); 457 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 458 459 const { did } = 460 bearer && bearer !== "null" 461 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 462 : {}; 463 464 if (!did) { 465 c.status(401); 466 return c.text("Unauthorized"); 467 } 468 469 const user = await ctx.db 470 .select() 471 .from(users) 472 .where(eq(users.did, did)) 473 .limit(1) 474 .then((rows) => rows[0]); 475 476 if (!user) { 477 c.status(401); 478 return c.text("Unauthorized"); 479 } 480 481 const spotifyToken = await ctx.db 482 .select() 483 .from(spotifyTokens) 484 .leftJoin( 485 spotifyApps, 486 eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), 487 ) 488 .where(eq(spotifyTokens.userId, user.id)) 489 .limit(1) 490 .then((rows) => rows[0]); 491 492 if (!spotifyToken) { 493 c.status(401); 494 return c.text("Unauthorized"); 495 } 496 497 const refreshToken = decrypt( 498 spotifyToken.spotify_tokens.refreshToken, 499 env.SPOTIFY_ENCRYPTION_KEY, 500 ); 501 502 // get new access token 503 const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 504 method: "POST", 505 headers: { 506 "Content-Type": "application/x-www-form-urlencoded", 507 }, 508 body: new URLSearchParams({ 509 grant_type: "refresh_token", 510 refresh_token: refreshToken, 511 client_id: spotifyToken.spotify_apps.spotifyAppId, 512 client_secret: decrypt( 513 spotifyToken.spotify_apps.spotifySecret, 514 env.SPOTIFY_ENCRYPTION_KEY, 515 ), 516 }), 517 }); 518 519 const { access_token } = (await newAccessToken.json()) as { 520 access_token: string; 521 }; 522 523 const response = await fetch("https://api.spotify.com/v1/me/player/play", { 524 method: "PUT", 525 headers: { 526 Authorization: `Bearer ${access_token}`, 527 }, 528 }); 529 530 if (response.status === 403) { 531 c.status(403); 532 return c.text(await response.text()); 533 } 534 535 return c.json(await response.json()); 536}); 537 538app.post("/next", async (c) => { 539 requestCounter.add(1, { method: "POST", route: "/spotify/next" }); 540 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 541 542 const { did } = 543 bearer && bearer !== "null" 544 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 545 : {}; 546 547 if (!did) { 548 c.status(401); 549 return c.text("Unauthorized"); 550 } 551 552 const user = await ctx.db 553 .select() 554 .from(users) 555 .where(eq(users.did, did)) 556 .limit(1) 557 .then((rows) => rows[0]); 558 559 if (!user) { 560 c.status(401); 561 return c.text("Unauthorized"); 562 } 563 564 const spotifyToken = await ctx.db 565 .select() 566 .from(spotifyTokens) 567 .leftJoin( 568 spotifyApps, 569 eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), 570 ) 571 .where(eq(spotifyTokens.userId, user.id)) 572 .limit(1) 573 .then((rows) => rows[0]); 574 575 if (!spotifyToken) { 576 c.status(401); 577 return c.text("Unauthorized"); 578 } 579 580 const refreshToken = decrypt( 581 spotifyToken.spotify_tokens.refreshToken, 582 env.SPOTIFY_ENCRYPTION_KEY, 583 ); 584 585 // get new access token 586 const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 587 method: "POST", 588 headers: { 589 "Content-Type": "application/x-www-form-urlencoded", 590 }, 591 body: new URLSearchParams({ 592 grant_type: "refresh_token", 593 refresh_token: refreshToken, 594 client_id: spotifyToken.spotify_apps.spotifyAppId, 595 client_secret: decrypt( 596 spotifyToken.spotify_apps.spotifySecret, 597 env.SPOTIFY_ENCRYPTION_KEY, 598 ), 599 }), 600 }); 601 602 const { access_token } = (await newAccessToken.json()) as { 603 access_token: string; 604 }; 605 606 const response = await fetch("https://api.spotify.com/v1/me/player/next", { 607 method: "POST", 608 headers: { 609 Authorization: `Bearer ${access_token}`, 610 }, 611 }); 612 613 if (response.status === 403) { 614 c.status(403); 615 return c.text(await response.text()); 616 } 617 618 return c.json(await response.json()); 619}); 620 621app.post("/previous", async (c) => { 622 requestCounter.add(1, { method: "POST", route: "/spotify/previous" }); 623 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 624 625 const { did } = 626 bearer && bearer !== "null" 627 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 628 : {}; 629 630 if (!did) { 631 c.status(401); 632 return c.text("Unauthorized"); 633 } 634 635 const user = await ctx.db 636 .select() 637 .from(users) 638 .where(eq(users.did, did)) 639 .limit(1) 640 .then((rows) => rows[0]); 641 642 if (!user) { 643 c.status(401); 644 return c.text("Unauthorized"); 645 } 646 647 const spotifyToken = await ctx.db 648 .select() 649 .from(spotifyTokens) 650 .leftJoin( 651 spotifyApps, 652 eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), 653 ) 654 .where(eq(spotifyTokens.userId, user.id)) 655 .limit(1) 656 .then((rows) => rows[0]); 657 658 if (!spotifyToken) { 659 c.status(401); 660 return c.text("Unauthorized"); 661 } 662 663 const refreshToken = decrypt( 664 spotifyToken.spotify_tokens.refreshToken, 665 env.SPOTIFY_ENCRYPTION_KEY, 666 ); 667 668 // get new access token 669 const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 670 method: "POST", 671 headers: { 672 "Content-Type": "application/x-www-form-urlencoded", 673 }, 674 body: new URLSearchParams({ 675 grant_type: "refresh_token", 676 refresh_token: refreshToken, 677 client_id: spotifyToken.spotify_apps.spotifyAppId, 678 client_secret: decrypt( 679 spotifyToken.spotify_apps.spotifySecret, 680 env.SPOTIFY_ENCRYPTION_KEY, 681 ), 682 }), 683 }); 684 685 const { access_token } = (await newAccessToken.json()) as { 686 access_token: string; 687 }; 688 689 const response = await fetch( 690 "https://api.spotify.com/v1/me/player/previous", 691 { 692 method: "POST", 693 headers: { 694 Authorization: `Bearer ${access_token}`, 695 }, 696 }, 697 ); 698 699 if (response.status === 403) { 700 c.status(403); 701 return c.text(await response.text()); 702 } 703 704 return c.json(await response.json()); 705}); 706 707app.put("/seek", async (c) => { 708 requestCounter.add(1, { method: "PUT", route: "/spotify/seek" }); 709 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); 710 711 const { did } = 712 bearer && bearer !== "null" 713 ? jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }) 714 : {}; 715 716 if (!did) { 717 c.status(401); 718 return c.text("Unauthorized"); 719 } 720 721 const user = await ctx.db 722 .select() 723 .from(users) 724 .where(eq(users.did, did)) 725 .limit(1) 726 .then((rows) => rows[0]); 727 728 if (!user) { 729 c.status(401); 730 return c.text("Unauthorized"); 731 } 732 733 const spotifyToken = await ctx.db 734 .select() 735 .from(spotifyTokens) 736 .leftJoin( 737 spotifyApps, 738 eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), 739 ) 740 .where(eq(spotifyTokens.userId, user.id)) 741 .limit(1) 742 .then((rows) => rows[0]); 743 744 if (!spotifyToken) { 745 c.status(401); 746 return c.text("Unauthorized"); 747 } 748 749 const refreshToken = decrypt( 750 spotifyToken.spotify_tokens.refreshToken, 751 env.SPOTIFY_ENCRYPTION_KEY, 752 ); 753 754 // get new access token 755 const newAccessToken = await fetch("https://accounts.spotify.com/api/token", { 756 method: "POST", 757 headers: { 758 "Content-Type": "application/x-www-form-urlencoded", 759 }, 760 body: new URLSearchParams({ 761 grant_type: "refresh_token", 762 refresh_token: refreshToken, 763 client_id: spotifyToken.spotify_apps.spotifyAppId, 764 client_secret: decrypt( 765 spotifyToken.spotify_apps.spotifySecret, 766 env.SPOTIFY_ENCRYPTION_KEY, 767 ), 768 }), 769 }); 770 771 const { access_token } = (await newAccessToken.json()) as { 772 access_token: string; 773 }; 774 775 const position = c.req.query("position_ms"); 776 const response = await fetch( 777 `https://api.spotify.com/v1/me/player/seek?position_ms=${position}`, 778 { 779 method: "PUT", 780 headers: { 781 Authorization: `Bearer ${access_token}`, 782 }, 783 }, 784 ); 785 786 if (response.status === 403) { 787 c.status(403); 788 return c.text(await response.text()); 789 } 790 791 return c.json(await response.json()); 792}); 793 794export default app;