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