forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
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;