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