forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
1import { equals } from "@xata.io/client";
2import axios from "axios";
3import { ctx } from "context";
4import fs from "fs";
5import { google } from "googleapis";
6import { Hono } from "hono";
7import jwt from "jsonwebtoken";
8import { encrypt } from "lib/crypto";
9import { env } from "lib/env";
10import { requestCounter } from "metrics";
11import { emailSchema } from "types/email";
12
13const app = new Hono();
14
15app.get("/login", async (c) => {
16 requestCounter.add(1, { method: "GET", route: "/googledrive/login" });
17 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
18
19 if (!bearer || bearer === "null") {
20 c.status(401);
21 return c.text("Unauthorized");
22 }
23
24 const { did } = jwt.verify(bearer, env.JWT_SECRET, {
25 ignoreExpiration: true,
26 });
27
28 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst();
29 if (!user) {
30 c.status(401);
31 return c.text("Unauthorized");
32 }
33
34 const credentials = JSON.parse(
35 fs.readFileSync("credentials.json").toString("utf-8"),
36 );
37 const { client_id, client_secret } = credentials.installed || credentials.web;
38 const oAuth2Client = new google.auth.OAuth2(
39 client_id,
40 client_secret,
41 env.GOOGLE_REDIRECT_URI,
42 );
43
44 // Generate Auth URL
45 const authUrl = oAuth2Client.generateAuthUrl({
46 access_type: "offline",
47 prompt: "consent",
48 scope: ["https://www.googleapis.com/auth/drive"],
49 state: user.xata_id,
50 });
51 return c.json({ authUrl });
52});
53
54app.get("/oauth/callback", async (c) => {
55 requestCounter.add(1, {
56 method: "GET",
57 route: "/googledrive/oauth/callback",
58 });
59 const params = new URLSearchParams(c.req.url.split("?")[1]);
60 const entries = Object.fromEntries(params.entries());
61
62 const credentials = JSON.parse(
63 fs.readFileSync("credentials.json").toString("utf-8"),
64 );
65 const { client_id, client_secret } = credentials.installed || credentials.web;
66
67 const response = await axios.postForm("https://oauth2.googleapis.com/token", {
68 code: entries.code,
69 client_id,
70 client_secret,
71 redirect_uri: env.GOOGLE_REDIRECT_URI,
72 grant_type: "authorization_code",
73 });
74
75 const googledrive = await ctx.client.db.google_drive
76 .select(["*", "user_id.*", "google_drive_token_id.*"])
77 .filter("user_id.xata_id", equals(entries.state))
78 .getFirst();
79
80 const newGoogleDriveToken =
81 await ctx.client.db.google_drive_tokens.createOrUpdate(
82 googledrive?.google_drive_token_id?.xata_id,
83 {
84 refresh_token: encrypt(
85 response.data.refresh_token,
86 env.SPOTIFY_ENCRYPTION_KEY,
87 ),
88 },
89 );
90
91 await ctx.client.db.google_drive.createOrUpdate(googledrive?.xata_id, {
92 google_drive_token_id: newGoogleDriveToken.xata_id,
93 user_id: entries.state,
94 });
95
96 return c.redirect(`${env.FRONTEND_URL}/googledrive`);
97});
98
99app.post("/join", async (c) => {
100 requestCounter.add(1, { method: "POST", route: "/googledrive/join" });
101 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
102
103 if (!bearer || bearer === "null") {
104 c.status(401);
105 return c.text("Unauthorized");
106 }
107
108 const { did } = jwt.verify(bearer, env.JWT_SECRET, {
109 ignoreExpiration: true,
110 });
111
112 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst();
113 if (!user) {
114 c.status(401);
115 return c.text("Unauthorized");
116 }
117
118 const body = await c.req.json();
119 const parsed = emailSchema.safeParse(body);
120
121 if (parsed.error) {
122 c.status(400);
123 return c.text("Invalid email: " + parsed.error.message);
124 }
125
126 const { email } = parsed.data;
127
128 try {
129 await ctx.client.db.google_drive_accounts.create({
130 user_id: user.xata_id,
131 email,
132 is_beta_user: false,
133 });
134 } catch (e) {
135 if (
136 !e.message.includes("invalid record: column [user_id]: is not unique")
137 ) {
138 console.error(e.message);
139 } else {
140 throw e;
141 }
142 }
143
144 await fetch("https://beta.rocksky.app", {
145 method: "POST",
146 headers: {
147 "Content-Type": "application/json",
148 Authorization: `Bearer ${env.ROCKSKY_BETA_TOKEN}`,
149 },
150 body: JSON.stringify({ email }),
151 });
152
153 return c.json({ status: "ok" });
154});
155
156app.get("/files", async (c) => {
157 requestCounter.add(1, { method: "GET", route: "/googledrive/files" });
158 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
159
160 if (!bearer || bearer === "null") {
161 c.status(401);
162 return c.text("Unauthorized");
163 }
164
165 const { did } = jwt.verify(bearer, env.JWT_SECRET, {
166 ignoreExpiration: true,
167 });
168
169 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst();
170 if (!user) {
171 c.status(401);
172 return c.text("Unauthorized");
173 }
174
175 const parent_id = c.req.query("parent_id");
176
177 try {
178 if (parent_id) {
179 const { data } = await ctx.googledrive.post(
180 "googledrive.getFilesInParents",
181 {
182 did,
183 parent_id,
184 },
185 );
186 return c.json(data);
187 }
188
189 let response = await ctx.googledrive.post("googledrive.getMusicDirectory", {
190 did,
191 });
192
193 if (response.data.files.length === 0) {
194 await ctx.googledrive.post("googledrive.createMusicDirectory", { did });
195 response = await ctx.googledrive.post("googledrive.getMusicDirectory", {
196 did,
197 });
198 }
199
200 const { data } = await ctx.googledrive.post(
201 "googledrive.getFilesInParents",
202 {
203 did,
204 parent_id: response.data.files[0].id,
205 },
206 );
207 return c.json(data);
208 } catch (error) {
209 if (axios.isAxiosError(error)) {
210 console.error("Axios error:", error.response?.data || error.message);
211
212 const credentials = JSON.parse(
213 fs.readFileSync("credentials.json").toString("utf-8"),
214 );
215 const { client_id, client_secret } =
216 credentials.installed || credentials.web;
217 const oAuth2Client = new google.auth.OAuth2(
218 client_id,
219 client_secret,
220 env.GOOGLE_REDIRECT_URI,
221 );
222
223 // Generate Auth URL
224 const authUrl = oAuth2Client.generateAuthUrl({
225 access_type: "offline",
226 prompt: "consent",
227 scope: ["https://www.googleapis.com/auth/drive"],
228 state: user.xata_id,
229 });
230
231 return c.json({
232 error: "Failed to fetch files",
233 authUrl,
234 });
235 }
236 }
237});
238
239app.get("/files/:id", async (c) => {
240 requestCounter.add(1, { method: "GET", route: "/googledrive/files/:id" });
241 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
242
243 if (!bearer || bearer === "null") {
244 c.status(401);
245 return c.text("Unauthorized");
246 }
247
248 const { did } = jwt.verify(bearer, env.JWT_SECRET, {
249 ignoreExpiration: true,
250 });
251
252 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst();
253 if (!user) {
254 c.status(401);
255 return c.text("Unauthorized");
256 }
257
258 const id = c.req.param("id");
259 const response = await ctx.googledrive.post("googledrive.getFile", {
260 did,
261 file_id: id,
262 });
263
264 return c.json(response.data);
265});
266
267app.get("/files/:id/download", async (c) => {
268 requestCounter.add(1, {
269 method: "GET",
270 route: "/googledrive/files/:id/download",
271 });
272 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
273
274 if (!bearer || bearer === "null") {
275 c.status(401);
276 return c.text("Unauthorized");
277 }
278
279 const { did } = jwt.verify(bearer, env.JWT_SECRET, {
280 ignoreExpiration: true,
281 });
282
283 const user = await ctx.client.db.users.filter("did", equals(did)).getFirst();
284 if (!user) {
285 c.status(401);
286 return c.text("Unauthorized");
287 }
288
289 const id = c.req.param("id");
290 const response = await ctx.googledrive.post("googledrive.downloadFile", {
291 did,
292 file_id: id,
293 });
294
295 c.header(
296 "Content-Type",
297 response.headers["content-type"] || "application/octet-stream",
298 );
299 c.header(
300 "Content-Disposition",
301 response.headers["content-disposition"] || "attachment",
302 );
303
304 return new Response(response.data, {
305 headers: c.res.headers,
306 });
307});
308
309export default app;