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