forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
1import type { BlobRef } from "@atproto/lexicon";
2import { isValidHandle } from "@atproto/syntax";
3import { equals } from "@xata.io/client";
4import { ctx } from "context";
5import { desc, eq } from "drizzle-orm";
6import { Hono } from "hono";
7import jwt from "jsonwebtoken";
8import * as Profile from "lexicon/types/app/bsky/actor/profile";
9import { createAgent } from "lib/agent";
10import { env } from "lib/env";
11import { requestCounter } from "metrics";
12import users from "schema/users";
13
14const app = new Hono();
15
16app.get("/login", async (c) => {
17 requestCounter.add(1, { method: "GET", route: "/login" });
18 const { handle, cli } = c.req.query();
19 if (typeof handle !== "string" || !isValidHandle(handle)) {
20 c.status(400);
21 return c.text("Invalid handle");
22 }
23 try {
24 const url = await ctx.oauthClient.authorize(handle, {
25 scope: "atproto transition:generic",
26 });
27 if (cli) {
28 ctx.kv.set(`cli:${handle}`, "1");
29 }
30 return c.redirect(url.toString());
31 } catch (e) {
32 c.status(500);
33 return c.text(e.toString());
34 }
35});
36
37app.post("/login", async (c) => {
38 requestCounter.add(1, { method: "POST", route: "/login" });
39 const { handle, cli } = await c.req.json();
40 if (typeof handle !== "string" || !isValidHandle(handle)) {
41 c.status(400);
42 return c.text("Invalid handle");
43 }
44
45 try {
46 const url = await ctx.oauthClient.authorize(handle, {
47 scope: "atproto transition:generic",
48 });
49
50 if (cli) {
51 ctx.kv.set(`cli:${handle}`, "1");
52 }
53
54 return c.text(url.toString());
55 } catch (e) {
56 c.status(500);
57 return c.text(e.toString());
58 }
59});
60
61app.get("/oauth/callback", async (c) => {
62 requestCounter.add(1, { method: "GET", route: "/oauth/callback" });
63 const params = new URLSearchParams(c.req.url.split("?")[1]);
64 let did, cli;
65
66 try {
67 const { session } = await ctx.oauthClient.callback(params);
68 did = session.did;
69 const handle = await ctx.resolver.resolveDidToHandle(did);
70 cli = ctx.kv.get(`cli:${handle}`);
71 ctx.kv.delete(`cli:${handle}`);
72
73 const token = jwt.sign(
74 {
75 did,
76 exp: cli
77 ? Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 1000
78 : Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7,
79 },
80 env.JWT_SECRET,
81 );
82 ctx.kv.set(did, token);
83 } catch (err) {
84 console.error({ err }, "oauth callback failed");
85 return c.redirect(`${env.FRONTEND_URL}?error=1`);
86 }
87
88 const spotifyUser = await ctx.client.db.spotify_accounts
89 .filter("user_id.did", equals(did))
90 .filter("is_beta_user", equals(true))
91 .getFirst();
92
93 if (spotifyUser?.email) {
94 ctx.nc.publish("rocksky.spotify.user", Buffer.from(spotifyUser.email));
95 }
96
97 if (!cli) {
98 return c.redirect(`${env.FRONTEND_URL}?did=${did}`);
99 }
100
101 return c.redirect(`${env.FRONTEND_URL}?did=${did}&cli=${cli}`);
102});
103
104app.get("/profile", async (c) => {
105 requestCounter.add(1, { method: "GET", route: "/profile" });
106 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
107
108 if (!bearer || bearer === "null") {
109 c.status(401);
110 return c.text("Unauthorized");
111 }
112
113 const { did } = jwt.verify(bearer, env.JWT_SECRET, {
114 ignoreExpiration: true,
115 });
116
117 const agent = await createAgent(ctx.oauthClient, did);
118
119 if (!agent) {
120 c.status(401);
121 return c.text("Unauthorized");
122 }
123
124 const { data: profileRecord } = await agent.com.atproto.repo.getRecord({
125 repo: agent.assertDid,
126 collection: "app.bsky.actor.profile",
127 rkey: "self",
128 });
129 const handle = await ctx.resolver.resolveDidToHandle(did);
130 const profile: { handle?: string; displayName?: string; avatar?: BlobRef } =
131 Profile.isRecord(profileRecord.value) &&
132 Profile.validateRecord(profileRecord.value).success
133 ? { ...profileRecord.value, handle }
134 : {};
135
136 if (profile.handle) {
137 try {
138 await ctx.client.db.users.create({
139 did,
140 handle,
141 display_name: profile.displayName,
142 avatar: `https://cdn.bsky.app/img/avatar/plain/${did}/${profile.avatar.ref.toString()}@jpeg`,
143 });
144 } catch (e) {
145 if (!e.message.includes("invalid record: column [did]: is not unique")) {
146 console.error(e.message);
147 } else {
148 await ctx.db
149 .update(users)
150 .set({
151 handle,
152 displayName: profile.displayName,
153 avatar: `https://cdn.bsky.app/img/avatar/plain/${did}/${profile.avatar.ref.toString()}@jpeg`,
154 })
155 .where(eq(users.did, did))
156 .execute();
157 }
158 }
159
160 const [user, lastUser, previousLastUser] = await Promise.all([
161 ctx.client.db.users.select(["*"]).filter("did", equals(did)).getFirst(),
162 ctx.db
163 .select()
164 .from(users)
165 .orderBy(desc(users.createdAt))
166 .limit(1)
167 .execute(),
168 ctx.kv.get("lastUser"),
169 ]);
170
171 ctx.nc.publish("rocksky.user", Buffer.from(JSON.stringify(user)));
172
173 await ctx.kv.set("lastUser", lastUser[0].id);
174 // if (lastUser[0].id !== previousLastUser) {
175 // ctx.nc.publish("rocksky.user", Buffer.from(JSON.stringify(user)));
176 // }
177 }
178
179 const [spotifyUser, spotifyToken, googledrive, dropbox] = await Promise.all([
180 ctx.client.db.spotify_accounts
181 .select(["user_id.*", "email", "is_beta_user"])
182 .filter("user_id.did", equals(did))
183 .getFirst(),
184 ctx.client.db.spotify_tokens.filter("user_id.did", equals(did)).getFirst(),
185 ctx.client.db.google_drive_accounts
186 .select(["user_id.*", "email", "is_beta_user"])
187 .filter("user_id.did", equals(did))
188 .getFirst(),
189 ctx.client.db.dropbox_accounts
190 .select(["user_id.*", "email", "is_beta_user"])
191 .filter("user_id.did", equals(did))
192 .getFirst(),
193 ]);
194
195 return c.json({
196 ...profile,
197 spotifyUser,
198 spotifyConnected: !!spotifyToken,
199 googledrive,
200 dropbox,
201 did,
202 });
203});
204
205app.get("/client-metadata.json", async (c) => {
206 requestCounter.add(1, { method: "GET", route: "/client-metadata.json" });
207 return c.json(ctx.oauthClient.clientMetadata);
208});
209
210app.get("/token", async (c) => {
211 requestCounter.add(1, { method: "GET", route: "/token" });
212 const did = c.req.header("session-did");
213
214 if (typeof did !== "string" || !did || did === "null") {
215 c.status(401);
216 return c.text("Unauthorized");
217 }
218
219 const token = ctx.kv.get(did);
220
221 if (!token) {
222 c.status(401);
223 return c.text("Unauthorized");
224 }
225
226 ctx.kv.delete(did);
227
228 return c.json({ token });
229});
230
231export default app;