forked from
rocksky.app/rocksky
A decentralized music tracking and discovery platform built on AT Protocol 馃幍
1import type { BlobRef } from "@atproto/lexicon";
2import { equals } from "@xata.io/client";
3import { ctx } from "context";
4import {
5 aliasedTable,
6 asc,
7 count,
8 desc,
9 eq,
10 inArray,
11 or,
12 sql,
13} from "drizzle-orm";
14import { Hono } from "hono";
15import jwt from "jsonwebtoken";
16import * as Profile from "lexicon/types/app/bsky/actor/profile";
17import { createAgent } from "lib/agent";
18import { env } from "lib/env";
19import _ from "lodash";
20import { likeTrack, unLikeTrack } from "lovedtracks/lovedtracks.service";
21import { requestCounter } from "metrics";
22import * as R from "ramda";
23import tables from "schema";
24import {
25 createShout,
26 likeShout,
27 replyShout,
28 unlikeShout,
29} from "shouts/shouts.service";
30import { shoutSchema } from "types/shout";
31import type { Track } from "types/track";
32import { dedupeTracksKeepLyrics } from "./utils";
33
34const app = new Hono();
35
36app.get("/:did/likes", async (c) => {
37 requestCounter.add(1, { method: "GET", route: "/users/:did/likes" });
38 const did = c.req.param("did");
39 const size = +c.req.query("size") || 10;
40 const offset = +c.req.query("offset") || 0;
41
42 const lovedTracks = await ctx.client.db.loved_tracks
43 .select(["track_id.*", "user_id.*"])
44 .filter({
45 $any: [
46 {
47 "user_id.did": did,
48 },
49 {
50 "user_id.handle": did,
51 },
52 ],
53 })
54 .sort("xata_createdat", "desc")
55 .getPaginated({
56 pagination: {
57 size,
58 offset,
59 },
60 });
61 return c.json(lovedTracks);
62});
63
64app.get("/:handle/scrobbles", async (c) => {
65 requestCounter.add(1, { method: "GET", route: "/users/:handle/scrobbles" });
66 const handle = c.req.param("handle");
67
68 const size = +c.req.query("size") || 10;
69 const offset = +c.req.query("offset") || 0;
70
71 const { data } = await ctx.analytics.post("library.getScrobbles", {
72 user_did: handle,
73 pagination: {
74 skip: offset,
75 take: size,
76 },
77 });
78
79 return c.json(data);
80});
81
82app.get("/:did/albums", async (c) => {
83 requestCounter.add(1, { method: "GET", route: "/users/:did/albums" });
84 const did = c.req.param("did");
85 const size = +c.req.query("size") || 10;
86 const offset = +c.req.query("offset") || 0;
87
88 const { data } = await ctx.analytics.post("library.getTopAlbums", {
89 user_did: did,
90 pagination: {
91 skip: offset,
92 take: size,
93 },
94 });
95
96 return c.json(data.map((item) => ({ ...item, tags: [] })));
97});
98
99app.get("/:did/artists", async (c) => {
100 requestCounter.add(1, { method: "GET", route: "/users/:did/artists" });
101 const did = c.req.param("did");
102 const size = +c.req.query("size") || 10;
103 const offset = +c.req.query("offset") || 0;
104
105 const { data } = await ctx.analytics.post("library.getTopArtists", {
106 user_did: did,
107 pagination: {
108 skip: offset,
109 take: size,
110 },
111 });
112
113 return c.json(data.map((item) => ({ ...item, tags: [] })));
114});
115
116app.get("/:did/tracks", async (c) => {
117 requestCounter.add(1, { method: "GET", route: "/users/:did/tracks" });
118 const did = c.req.param("did");
119 const size = +c.req.query("size") || 10;
120 const offset = +c.req.query("offset") || 0;
121
122 const { data } = await ctx.analytics.post("library.getTopTracks", {
123 user_did: did,
124 pagination: {
125 skip: offset,
126 take: size,
127 },
128 });
129
130 return c.json(
131 data.map((item) => ({
132 ...item,
133 tags: [],
134 })),
135 );
136});
137
138app.get("/:did/playlists", async (c) => {
139 requestCounter.add(1, { method: "GET", route: "/users/:did/playlists" });
140 const did = c.req.param("did");
141 const size = +c.req.query("size") || 10;
142 const offset = +c.req.query("offset") || 0;
143
144 const results = await ctx.db
145 .select({
146 playlists: tables.playlists,
147 trackCount: sql<number>`
148 (SELECT COUNT(*)
149 FROM ${tables.playlistTracks}
150 WHERE ${tables.playlistTracks.playlistId} = ${tables.playlists.id}
151 )`.as("trackCount"),
152 })
153 .from(tables.playlists)
154 .leftJoin(tables.users, eq(tables.playlists.createdBy, tables.users.id))
155 .where(or(eq(tables.users.did, did), eq(tables.users.handle, did)))
156 .offset(offset)
157 .limit(size)
158 .execute();
159
160 return c.json(
161 results.map((x) => ({
162 ...x.playlists,
163 trackCount: +x.trackCount,
164 })),
165 );
166});
167
168app.get("/:did/app.rocksky.scrobble/:rkey", async (c) => {
169 requestCounter.add(1, {
170 method: "GET",
171 route: "/users/:did/app.rocksky.scrobble/:rkey",
172 });
173 const did = c.req.param("did");
174 const rkey = c.req.param("rkey");
175 const uri = `at://${did}/app.rocksky.scrobble/${rkey}`;
176
177 const scrobble = await ctx.client.db.scrobbles
178 .select(["track_id.*", "user_id.*", "xata_createdat", "uri"])
179 .filter("uri", equals(uri))
180 .getFirst();
181
182 if (!scrobble) {
183 c.status(404);
184 return c.text("Scrobble not found");
185 }
186
187 const [listeners, scrobbles] = await Promise.all([
188 ctx.client.db.user_tracks.select(["track_id.*"]).summarize({
189 filter: {
190 "track_id.xata_id": scrobble.track_id.xata_id,
191 },
192 columns: ["track_id.*"],
193 summaries: {
194 total: {
195 count: "*",
196 },
197 },
198 }),
199 ctx.client.db.scrobbles.select(["track_id.*", "xata_createdat"]).summarize({
200 filter: {
201 "track_id.xata_id": scrobble.track_id.xata_id,
202 },
203 columns: ["track_id.*"],
204 summaries: {
205 total: {
206 count: "*",
207 },
208 },
209 }),
210 ]);
211
212 return c.json({
213 ...R.omit(["xata_id"], scrobble),
214 id: scrobble.xata_id,
215 listeners: _.get(listeners.summaries, "0.total", 1),
216 scrobbles: _.get(scrobbles.summaries, "0.total", 1),
217 tags: [],
218 });
219});
220
221app.get("/:did/app.rocksky.artist/:rkey", async (c) => {
222 requestCounter.add(1, {
223 method: "GET",
224 route: "/users/:did/app.rocksky.artist/:rkey",
225 });
226 const did = c.req.param("did");
227 const rkey = c.req.param("rkey");
228 const uri = `at://${did}/app.rocksky.artist/${rkey}`;
229
230 const artist = await ctx.client.db.user_artists
231 .select(["artist_id.*"])
232 .filter({
233 $any: [{ uri }, { "artist_id.uri": uri }],
234 })
235 .getFirst();
236
237 if (!artist) {
238 c.status(404);
239 return c.text("Artist not found");
240 }
241
242 const [listeners, scrobbles] = await Promise.all([
243 ctx.client.db.user_artists.select(["artist_id.*"]).summarize({
244 filter: {
245 "artist_id.xata_id": equals(artist.artist_id.xata_id),
246 },
247 columns: ["artist_id.*"],
248 summaries: {
249 total: {
250 count: "*",
251 },
252 },
253 }),
254 ctx.client.db.scrobbles
255 .select(["artist_id.*", "xata_createdat"])
256 .summarize({
257 filter: {
258 "artist_id.xata_id": artist.artist_id.xata_id,
259 },
260 columns: ["artist_id.*"],
261 summaries: {
262 total: {
263 count: "*",
264 },
265 },
266 }),
267 ]);
268
269 return c.json({
270 ...R.omit(["xata_id"], artist.artist_id),
271 id: artist.artist_id.xata_id,
272 listeners: _.get(listeners.summaries, "0.total", 1),
273 scrobbles: _.get(scrobbles.summaries, "0.total", 1),
274 tags: [],
275 });
276});
277
278app.get("/:did/app.rocksky.album/:rkey", async (c) => {
279 requestCounter.add(1, {
280 method: "GET",
281 route: "/users/:did/app.rocksky.album/:rkey",
282 });
283
284 const did = c.req.param("did");
285 const rkey = c.req.param("rkey");
286 const uri = `at://${did}/app.rocksky.album/${rkey}`;
287
288 const album = await ctx.client.db.user_albums
289 .select(["album_id.*"])
290 .filter({
291 $any: [{ uri }, { "album_id.uri": uri }],
292 })
293 .getFirst();
294
295 if (!album) {
296 c.status(404);
297 return c.text("Album not found");
298 }
299
300 const tracks = await ctx.client.db.album_tracks
301 .select(["track_id.*"])
302 .filter("album_id.xata_id", equals(album.album_id.xata_id))
303 .sort("track_id.track_number", "asc")
304 .getAll();
305
306 const [listeners, scrobbles] = await Promise.all([
307 ctx.client.db.user_albums.select(["album_id.*"]).summarize({
308 filter: {
309 "album_id.xata_id": equals(album.album_id.xata_id),
310 },
311 columns: ["album_id.*"],
312 summaries: {
313 total: {
314 count: "*",
315 },
316 },
317 }),
318 ctx.client.db.scrobbles.select(["album_id.*", "xata_createdat"]).summarize({
319 filter: {
320 "album_id.xata_id": album.album_id.xata_id,
321 },
322 columns: ["album_id.*"],
323 summaries: {
324 total: {
325 count: "*",
326 },
327 },
328 }),
329 ]);
330
331 return c.json({
332 ...R.omit(["xata_id"], album.album_id),
333 id: album.album_id.xata_id,
334 listeners: _.get(listeners.summaries, "0.total", 1),
335 scrobbles: _.get(scrobbles.summaries, "0.total", 1),
336 label: _.get(tracks, "0.track_id.label", ""),
337 tracks: dedupeTracksKeepLyrics(tracks.map((track) => track.track_id)).sort(
338 (a, b) => a.track_number - b.track_number,
339 ),
340 tags: [],
341 });
342});
343
344app.get("/:did/app.rocksky.song/:rkey", async (c) => {
345 requestCounter.add(1, {
346 method: "GET",
347 route: "/users/:did/app.rocksky.song/:rkey",
348 });
349 const did = c.req.param("did");
350 const rkey = c.req.param("rkey");
351 const uri = `at://${did}/app.rocksky.song/${rkey}`;
352
353 const [_track, user_track] = await Promise.all([
354 ctx.client.db.tracks.filter("uri", equals(uri)).getFirst(),
355 ctx.client.db.user_tracks
356 .select(["track_id.*"])
357 .filter("uri", equals(uri))
358 .getFirst(),
359 ]);
360 const track = _track || user_track.track_id;
361
362 if (!track) {
363 c.status(404);
364 return c.text("Track not found");
365 }
366
367 const [listeners, scrobbles] = await Promise.all([
368 ctx.client.db.user_tracks.select(["track_id.*"]).summarize({
369 filter: {
370 "track_id.xata_id": equals(track.xata_id),
371 },
372 columns: ["track_id.*"],
373 summaries: {
374 total: {
375 count: "*",
376 },
377 },
378 }),
379 ctx.client.db.scrobbles.select(["track_id.*", "xata_createdat"]).summarize({
380 filter: {
381 "track_id.xata_id": track.xata_id,
382 },
383 columns: ["track_id.*"],
384 summaries: {
385 total: {
386 count: "*",
387 },
388 },
389 }),
390 ]);
391
392 return c.json({
393 ...R.omit(["xata_id"], track),
394 id: track.xata_id,
395 tags: [],
396 listeners: _.get(listeners.summaries, "0.total", 1),
397 scrobbles: _.get(scrobbles.summaries, "0.total", 1),
398 });
399});
400
401app.get("/:did/app.rocksky.artist/:rkey/tracks", async (c) => {
402 requestCounter.add(1, {
403 method: "GET",
404 route: "/users/:did/app.rocksky.artist/:rkey/tracks",
405 });
406 const did = c.req.param("did");
407 const rkey = c.req.param("rkey");
408 const uri = `at://${did}/app.rocksky.artist/${rkey}`;
409 const size = +c.req.query("size") || 10;
410 const offset = +c.req.query("offset") || 0;
411
412 const tracks = await ctx.client.db.artist_tracks
413 .select(["track_id.*", "xata_version"])
414 .filter({
415 "artist_id.uri": equals(uri),
416 })
417 .sort("xata_version", "desc")
418 .getPaginated({
419 pagination: {
420 size,
421 offset,
422 },
423 });
424 return c.json(
425 tracks.records.map((item) => ({
426 ...R.omit(["xata_id"], item.track_id),
427 id: item.track_id.xata_id,
428 xata_version: item.xata_version,
429 })),
430 );
431});
432
433app.get("/:did/app.rocksky.artist/:rkey/albums", async (c) => {
434 requestCounter.add(1, {
435 method: "GET",
436 route: "/users/:did/app.rocksky.artist/:rkey/albums",
437 });
438 const did = c.req.param("did");
439 const rkey = c.req.param("rkey");
440 const uri = `at://${did}/app.rocksky.artist/${rkey}`;
441 const size = +c.req.query("size") || 10;
442 const offset = +c.req.query("offset") || 0;
443
444 const albums = await ctx.client.db.artist_albums
445 .select(["album_id.*", "xata_version"])
446 .filter({
447 "artist_id.uri": equals(uri),
448 })
449 .sort("xata_version", "desc")
450 .getPaginated({
451 pagination: {
452 size,
453 offset,
454 },
455 });
456 return c.json(
457 R.uniqBy(
458 (item) => item.id,
459 albums.records.map((item) => ({
460 ...R.omit(["xata_id"], item.album_id),
461 id: item.album_id.xata_id,
462 xata_version: item.xata_version,
463 })),
464 ),
465 );
466});
467
468app.get("/:did/app.rocksky.playlist/:rkey", async (c) => {
469 requestCounter.add(1, {
470 method: "GET",
471 route: "/users/:did/app.rocksky.playlist/:rkey",
472 });
473 const did = c.req.param("did");
474 const rkey = c.req.param("rkey");
475 const uri = `at://${did}/app.rocksky.playlist/${rkey}`;
476
477 const playlist = await ctx.db
478 .select()
479 .from(tables.playlists)
480 .leftJoin(tables.users, eq(tables.playlists.createdBy, tables.users.id))
481 .where(eq(tables.playlists.uri, uri))
482 .execute();
483
484 if (!playlist.length) {
485 c.status(404);
486 return c.text("Playlist not found");
487 }
488
489 const results = await ctx.db
490 .select()
491 .from(tables.playlistTracks)
492 .leftJoin(
493 tables.playlists,
494 eq(tables.playlistTracks.playlistId, tables.playlists.id),
495 )
496 .leftJoin(
497 tables.tracks,
498 eq(tables.playlistTracks.trackId, tables.tracks.id),
499 )
500 .where(eq(tables.playlists.uri, uri))
501 .groupBy(
502 tables.playlistTracks.id,
503 tables.playlistTracks.playlistId,
504 tables.playlistTracks.trackId,
505 tables.playlistTracks.createdAt,
506 tables.tracks.id,
507 tables.tracks.title,
508 tables.tracks.artist,
509 tables.tracks.albumArtist,
510 tables.tracks.albumArt,
511 tables.tracks.album,
512 tables.tracks.trackNumber,
513 tables.tracks.duration,
514 tables.tracks.mbId,
515 tables.tracks.youtubeLink,
516 tables.tracks.spotifyLink,
517 tables.tracks.appleMusicLink,
518 tables.tracks.tidalLink,
519 tables.tracks.sha256,
520 tables.tracks.discNumber,
521 tables.tracks.lyrics,
522 tables.tracks.composer,
523 tables.tracks.genre,
524 tables.tracks.copyrightMessage,
525 tables.tracks.uri,
526 tables.tracks.albumUri,
527 tables.tracks.artistUri,
528 tables.tracks.createdAt,
529 tables.tracks.updatedAt,
530 tables.tracks.label,
531 tables.tracks.xataVersion,
532 tables.playlists.updatedAt,
533 tables.playlists.id,
534 tables.playlists.createdAt,
535 tables.playlists.name,
536 tables.playlists.description,
537 tables.playlists.uri,
538 tables.playlists.createdBy,
539 tables.playlists.picture,
540 tables.playlists.spotifyLink,
541 tables.playlists.tidalLink,
542 tables.playlists.appleMusicLink,
543 )
544 .orderBy(asc(tables.playlistTracks.createdAt))
545 .execute();
546
547 return c.json({
548 ...playlist[0].playlists,
549 curatedBy: playlist[0].users,
550 tracks: results.map((x) => x.tracks),
551 });
552});
553
554app.get("/:did", async (c) => {
555 requestCounter.add(1, { method: "GET", route: "/users/:did" });
556 const did = c.req.param("did");
557 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
558 if (bearer && bearer !== "null") {
559 const claims = jwt.verify(bearer, env.JWT_SECRET, {
560 ignoreExpiration: true,
561 });
562 const agent = await createAgent(ctx.oauthClient, claims.did);
563
564 if (agent) {
565 const { data: profileRecord } = await agent.com.atproto.repo.getRecord({
566 repo: did,
567 collection: "app.bsky.actor.profile",
568 rkey: "self",
569 });
570 const handle = await ctx.resolver.resolveDidToHandle(did);
571 const profile: {
572 handle?: string;
573 displayName?: string;
574 avatar?: BlobRef;
575 } =
576 Profile.isRecord(profileRecord.value) &&
577 Profile.validateRecord(profileRecord.value).success
578 ? { ...profileRecord.value, handle }
579 : {};
580
581 if (profile.handle) {
582 await ctx.db
583 .update(tables.users)
584 .set({
585 handle,
586 displayName: profile.displayName,
587 avatar: `https://cdn.bsky.app/img/avatar/plain/${did}/${profile.avatar.ref.toString()}@jpeg`,
588 })
589 .where(eq(tables.users.did, did))
590 .execute();
591
592 const user = await ctx.client.db.users
593 .select(["*"])
594 .filter("did", equals(did))
595 .getFirst();
596
597 ctx.nc.publish("rocksky.user", Buffer.from(JSON.stringify(user)));
598 }
599 }
600 }
601
602 const user = await ctx.client.db.users
603 .filter({
604 $any: [{ did }, { handle: did }],
605 })
606 .getFirst();
607
608 if (!user) {
609 c.status(404);
610 return c.text("User not found");
611 }
612
613 return c.json(user);
614});
615
616app.post("/:did/app.rocksky.artist/:rkey/shouts", async (c) => {
617 requestCounter.add(1, {
618 method: "POST",
619 route: "/users/:did/app.rocksky.artist/:rkey/shouts",
620 });
621 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
622
623 if (!bearer || bearer === "null") {
624 c.status(401);
625 return c.text("Unauthorized");
626 }
627
628 const payload = jwt.verify(bearer, env.JWT_SECRET, {
629 ignoreExpiration: true,
630 });
631 const agent = await createAgent(ctx.oauthClient, payload.did);
632
633 const user = await ctx.client.db.users
634 .filter("did", equals(payload.did))
635 .getFirst();
636 if (!user) {
637 c.status(401);
638 return c.text("Unauthorized");
639 }
640
641 const did = c.req.param("did");
642 const rkey = c.req.param("rkey");
643 const body = await c.req.json();
644 const parsed = shoutSchema.safeParse(body);
645
646 if (parsed.error) {
647 c.status(400);
648 return c.text("Invalid shout data: " + parsed.error.message);
649 }
650
651 await createShout(
652 ctx,
653 parsed.data,
654 `at://${did}/app.rocksky.artist/${rkey}`,
655 user,
656 agent,
657 );
658 return c.json({});
659});
660
661app.post("/:did/app.rocksky.album/:rkey/shouts", async (c) => {
662 requestCounter.add(1, {
663 method: "POST",
664 route: "/users/:did/app.rocksky.album/:rkey/shouts",
665 });
666 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
667
668 if (!bearer || bearer === "null") {
669 c.status(401);
670 return c.text("Unauthorized");
671 }
672
673 const payload = jwt.verify(bearer, env.JWT_SECRET, {
674 ignoreExpiration: true,
675 });
676 const agent = await createAgent(ctx.oauthClient, payload.did);
677
678 const user = await ctx.client.db.users
679 .filter("did", equals(payload.did))
680 .getFirst();
681 if (!user) {
682 c.status(401);
683 return c.text("Unauthorized");
684 }
685
686 const did = c.req.param("did");
687 const rkey = c.req.param("rkey");
688 const body = await c.req.json();
689 const parsed = shoutSchema.safeParse(body);
690
691 if (parsed.error) {
692 c.status(400);
693 return c.text("Invalid shout data: " + parsed.error.message);
694 }
695
696 await createShout(
697 ctx,
698 parsed.data,
699 `at://${did}/app.rocksky.album/${rkey}`,
700 user,
701 agent,
702 );
703 return c.json({});
704});
705
706app.post("/:did/app.rocksky.song/:rkey/shouts", async (c) => {
707 requestCounter.add(1, {
708 method: "POST",
709 route: "/users/:did/app.rocksky.song/:rkey/shouts",
710 });
711 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
712
713 if (!bearer || bearer === "null") {
714 c.status(401);
715 return c.text("Unauthorized");
716 }
717
718 const payload = jwt.verify(bearer, env.JWT_SECRET, {
719 ignoreExpiration: true,
720 });
721 const agent = await createAgent(ctx.oauthClient, payload.did);
722
723 const user = await ctx.client.db.users
724 .filter("did", equals(payload.did))
725 .getFirst();
726 if (!user) {
727 c.status(401);
728 return c.text("Unauthorized");
729 }
730
731 const did = c.req.param("did");
732 const rkey = c.req.param("rkey");
733 const body = await c.req.json();
734
735 const parsed = shoutSchema.safeParse(body);
736 if (parsed.error) {
737 c.status(400);
738 return c.text("Invalid shout data: " + parsed.error.message);
739 }
740
741 await createShout(
742 ctx,
743 parsed.data,
744 `at://${did}/app.rocksky.song/${rkey}`,
745 user,
746 agent,
747 );
748
749 return c.json({});
750});
751
752app.post("/:did/app.rocksky.scrobble/:rkey/shouts", async (c) => {
753 requestCounter.add(1, {
754 method: "POST",
755 route: "/users/:did/app.rocksky.scrobble/:rkey/shouts",
756 });
757 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
758
759 if (!bearer || bearer === "null") {
760 c.status(401);
761 return c.text("Unauthorized");
762 }
763
764 const payload = jwt.verify(bearer, env.JWT_SECRET, {
765 ignoreExpiration: true,
766 });
767 const agent = await createAgent(ctx.oauthClient, payload.did);
768
769 const user = await ctx.client.db.users
770 .filter("did", equals(payload.did))
771 .getFirst();
772 if (!user) {
773 c.status(401);
774 return c.text("Unauthorized");
775 }
776
777 const did = c.req.param("did");
778 const rkey = c.req.param("rkey");
779 const body = await c.req.json();
780
781 const parsed = shoutSchema.safeParse(body);
782 if (parsed.error) {
783 c.status(400);
784 return c.text("Invalid shout data: " + parsed.error.message);
785 }
786
787 await createShout(
788 ctx,
789 parsed.data,
790 `at://${did}/app.rocksky.scrobble/${rkey}`,
791 user,
792 agent,
793 );
794
795 return c.json({});
796});
797
798app.post("/:did/shouts", async (c) => {
799 requestCounter.add(1, { method: "POST", route: "/users/:did/shouts" });
800 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
801
802 if (!bearer || bearer === "null") {
803 c.status(401);
804 return c.text("Unauthorized");
805 }
806
807 const payload = jwt.verify(bearer, env.JWT_SECRET, {
808 ignoreExpiration: true,
809 });
810 const agent = await createAgent(ctx.oauthClient, payload.did);
811
812 const user = await ctx.client.db.users
813 .filter("did", equals(payload.did))
814 .getFirst();
815 if (!user) {
816 c.status(401);
817 return c.text("Unauthorized");
818 }
819
820 const did = c.req.param("did");
821 const body = await c.req.json();
822 const parsed = shoutSchema.safeParse(body);
823 if (parsed.error) {
824 c.status(400);
825 return c.text("Invalid shout data: " + parsed.error.message);
826 }
827
828 const _user = await ctx.client.db.users
829 .filter({
830 $any: [{ did }, { handle: did }],
831 })
832 .getFirst();
833
834 if (!_user) {
835 c.status(404);
836 return c.text("User not found");
837 }
838
839 await createShout(ctx, parsed.data, `at://${_user.did}`, user, agent);
840
841 return c.json({});
842});
843
844app.post("/:did/app.rocksky.shout/:rkey/likes", async (c) => {
845 requestCounter.add(1, {
846 method: "POST",
847 route: "/users/:did/app.rocksky.shout/:rkey/likes",
848 });
849 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
850
851 if (!bearer || bearer === "null") {
852 c.status(401);
853 return c.text("Unauthorized");
854 }
855
856 const payload = jwt.verify(bearer, env.JWT_SECRET, {
857 ignoreExpiration: true,
858 });
859 const agent = await createAgent(ctx.oauthClient, payload.did);
860
861 const user = await ctx.client.db.users
862 .filter("did", equals(payload.did))
863 .getFirst();
864 if (!user) {
865 c.status(401);
866 return c.text("Unauthorized");
867 }
868
869 const did = c.req.param("did");
870 const rkey = c.req.param("rkey");
871 await likeShout(ctx, `at://${did}/app.rocksky.shout/${rkey}`, user, agent);
872
873 return c.json({});
874});
875
876app.delete("/:did/app.rocksky.shout/:rkey/likes", async (c) => {
877 requestCounter.add(1, {
878 method: "DELETE",
879 route: "/users/:did/app.rocksky.shout/:rkey/likes",
880 });
881 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
882
883 if (!bearer || bearer === "null") {
884 c.status(401);
885 return c.text("Unauthorized");
886 }
887
888 const payload = jwt.verify(bearer, env.JWT_SECRET, {
889 ignoreExpiration: true,
890 });
891 const agent = await createAgent(ctx.oauthClient, payload.did);
892
893 const user = await ctx.client.db.users
894 .filter("did", equals(payload.did))
895 .getFirst();
896 if (!user) {
897 c.status(401);
898 return c.text("Unauthorized");
899 }
900
901 const did = c.req.param("did");
902 const rkey = c.req.param("rkey");
903
904 await unlikeShout(ctx, `at://${did}/app.rocksky.shout/${rkey}`, user, agent);
905
906 return c.json({});
907});
908
909app.post("/:did/app.rocksky.song/:rkey/likes", async (c) => {
910 requestCounter.add(1, {
911 method: "POST",
912 route: "/users/:did/app.rocksky.song/:rkey/likes",
913 });
914 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
915
916 if (!bearer || bearer === "null") {
917 c.status(401);
918 return c.text("Unauthorized");
919 }
920
921 const payload = jwt.verify(bearer, env.JWT_SECRET, {
922 ignoreExpiration: true,
923 });
924 const agent = await createAgent(ctx.oauthClient, payload.did);
925
926 const user = await ctx.client.db.users
927 .filter("did", equals(payload.did))
928 .getFirst();
929 if (!user) {
930 c.status(401);
931 return c.text("Unauthorized");
932 }
933
934 const did = c.req.param("did");
935 const rkey = c.req.param("rkey");
936
937 const result = await ctx.client.db.tracks
938 .filter("uri", equals(`at://${did}/app.rocksky.song/${rkey}`))
939 .getFirst();
940
941 const track: Track = {
942 title: result.title,
943 artist: result.artist,
944 album: result.album,
945 albumArt: result.album_art,
946 albumArtist: result.album_artist,
947 trackNumber: result.track_number,
948 duration: result.duration,
949 composer: result.composer,
950 lyrics: result.lyrics,
951 discNumber: result.disc_number,
952 };
953 await likeTrack(ctx, track, user, agent);
954
955 return c.json({});
956});
957
958app.delete("/:did/app.rocksky.song/:rkey/likes", async (c) => {
959 requestCounter.add(1, {
960 method: "DELETE",
961 route: "/users/:did/app.rocksky.song/:rkey/likes",
962 });
963 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
964
965 if (!bearer || bearer === "null") {
966 c.status(401);
967 return c.text("Unauthorized");
968 }
969
970 const payload = jwt.verify(bearer, env.JWT_SECRET, {
971 ignoreExpiration: true,
972 });
973 const agent = await createAgent(ctx.oauthClient, payload.did);
974
975 const user = await ctx.client.db.users
976 .filter("did", equals(payload.did))
977 .getFirst();
978 if (!user) {
979 c.status(401);
980 return c.text("Unauthorized");
981 }
982
983 const did = c.req.param("did");
984 const rkey = c.req.param("rkey");
985
986 const track = await ctx.client.db.tracks
987 .filter("uri", equals(`at://${did}/app.rocksky.song/${rkey}`))
988 .getFirst();
989
990 if (!track) {
991 c.status(404);
992 return c.text("Track not found");
993 }
994
995 await unLikeTrack(ctx, track.sha256, user, agent);
996
997 return c.json([]);
998});
999
1000app.post("/:did/app.rocksky.shout/:rkey/replies", async (c) => {
1001 requestCounter.add(1, {
1002 method: "POST",
1003 route: "/users/:did/app.rocksky.shout/:rkey/replies",
1004 });
1005 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
1006
1007 if (!bearer || bearer === "null") {
1008 c.status(401);
1009 return c.text("Unauthorized");
1010 }
1011
1012 const payload = jwt.verify(bearer, env.JWT_SECRET, {
1013 ignoreExpiration: true,
1014 });
1015 const agent = await createAgent(ctx.oauthClient, payload.did);
1016
1017 const user = await ctx.client.db.users
1018 .filter("did", equals(payload.did))
1019 .getFirst();
1020 if (!user) {
1021 c.status(401);
1022 return c.text("Unauthorized");
1023 }
1024
1025 const did = c.req.param("did");
1026 const rkey = c.req.param("rkey");
1027
1028 const body = await c.req.json();
1029 const parsed = shoutSchema.safeParse(body);
1030
1031 if (parsed.error) {
1032 c.status(400);
1033 return c.text("Invalid shout data: " + parsed.error.message);
1034 }
1035
1036 await replyShout(
1037 ctx,
1038 parsed.data,
1039 `at://${did}/app.rocksky.shout/${rkey}`,
1040 user,
1041 agent,
1042 );
1043 return c.json({});
1044});
1045
1046app.get("/:did/app.rocksky.artist/:rkey/shouts", async (c) => {
1047 requestCounter.add(1, {
1048 method: "GET",
1049 route: "/users/:did/app.rocksky.artist/:rkey/shouts",
1050 });
1051 const did = c.req.param("did");
1052 const rkey = c.req.param("rkey");
1053
1054 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
1055
1056 let user;
1057 if (bearer && bearer !== "null") {
1058 const payload = jwt.verify(bearer, env.JWT_SECRET, {
1059 ignoreExpiration: true,
1060 });
1061
1062 user = await ctx.client.db.users
1063 .filter("did", equals(payload.did))
1064 .getFirst();
1065 }
1066
1067 const shouts = await ctx.db
1068 .select({
1069 shouts: user
1070 ? {
1071 id: tables.shouts.id,
1072 content: tables.shouts.content,
1073 createdAt: tables.shouts.createdAt,
1074 uri: tables.shouts.uri,
1075 parent: tables.shouts.parentId,
1076 likes: count(tables.shoutLikes.id).as("likes"),
1077 liked: sql<boolean>`
1078 EXISTS (
1079 SELECT 1
1080 FROM ${tables.shoutLikes}
1081 WHERE ${tables.shoutLikes}.shout_id = ${tables.shouts}.xata_id
1082 AND ${tables.shoutLikes}.user_id = ${user.xata_id}
1083 )`.as("liked"),
1084 }
1085 : {
1086 id: tables.shouts.id,
1087 content: tables.shouts.content,
1088 createdAt: tables.shouts.createdAt,
1089 parent: tables.shouts.parentId,
1090 uri: tables.shouts.uri,
1091 likes: count(tables.shoutLikes.id).as("likes"),
1092 },
1093 users: {
1094 id: tables.users.id,
1095 did: tables.users.did,
1096 handle: tables.users.handle,
1097 displayName: tables.users.displayName,
1098 avatar: tables.users.avatar,
1099 },
1100 })
1101 .from(tables.shouts)
1102 .leftJoin(tables.users, eq(tables.shouts.authorId, tables.users.id))
1103 .leftJoin(tables.artists, eq(tables.shouts.artistId, tables.artists.id))
1104 .leftJoin(
1105 tables.shoutLikes,
1106 eq(tables.shouts.id, tables.shoutLikes.shoutId),
1107 )
1108 .where(eq(tables.artists.uri, `at://${did}/app.rocksky.artist/${rkey}`))
1109 .groupBy(
1110 tables.shouts.id,
1111 tables.shouts.content,
1112 tables.shouts.createdAt,
1113 tables.shouts.uri,
1114 tables.shouts.parentId,
1115 tables.users.id,
1116 tables.users.did,
1117 tables.users.handle,
1118 tables.users.displayName,
1119 tables.users.avatar,
1120 )
1121 .orderBy(desc(tables.shouts.createdAt))
1122 .execute();
1123 return c.json(shouts);
1124});
1125
1126app.get("/:did/app.rocksky.album/:rkey/shouts", async (c) => {
1127 requestCounter.add(1, {
1128 method: "GET",
1129 route: "/users/:did/app.rocksky.album/:rkey/shouts",
1130 });
1131
1132 const did = c.req.param("did");
1133 const rkey = c.req.param("rkey");
1134
1135 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
1136
1137 let user;
1138 if (bearer && bearer !== "null") {
1139 const payload = jwt.verify(bearer, env.JWT_SECRET, {
1140 ignoreExpiration: true,
1141 });
1142
1143 user = await ctx.client.db.users
1144 .filter("did", equals(payload.did))
1145 .getFirst();
1146 }
1147
1148 const shouts = await ctx.db
1149 .select({
1150 shouts: user
1151 ? {
1152 id: tables.shouts.id,
1153 content: tables.shouts.content,
1154 createdAt: tables.shouts.createdAt,
1155 parent: tables.shouts.parentId,
1156 uri: tables.shouts.uri,
1157 likes: count(tables.shoutLikes.id).as("likes"),
1158 liked: sql<boolean>`
1159 EXISTS (
1160 SELECT 1
1161 FROM ${tables.shoutLikes}
1162 WHERE ${tables.shoutLikes}.shout_id = ${tables.shouts}.xata_id
1163 AND ${tables.shoutLikes}.user_id = ${user.xata_id}
1164 )`.as("liked"),
1165 }
1166 : {
1167 id: tables.shouts.id,
1168 content: tables.shouts.content,
1169 createdAt: tables.shouts.createdAt,
1170 parent: tables.shouts.parentId,
1171 uri: tables.shouts.uri,
1172 likes: count(tables.shoutLikes.id).as("likes"),
1173 },
1174 users: {
1175 id: tables.users.id,
1176 did: tables.users.did,
1177 handle: tables.users.handle,
1178 displayName: tables.users.displayName,
1179 avatar: tables.users.avatar,
1180 },
1181 })
1182 .from(tables.shouts)
1183 .leftJoin(tables.users, eq(tables.shouts.authorId, tables.users.id))
1184 .leftJoin(tables.albums, eq(tables.shouts.albumId, tables.albums.id))
1185 .leftJoin(
1186 tables.shoutLikes,
1187 eq(tables.shouts.id, tables.shoutLikes.shoutId),
1188 )
1189 .where(eq(tables.albums.uri, `at://${did}/app.rocksky.album/${rkey}`))
1190 .groupBy(
1191 tables.shouts.id,
1192 tables.shouts.content,
1193 tables.shouts.createdAt,
1194 tables.shouts.uri,
1195 tables.shouts.parentId,
1196 tables.users.id,
1197 tables.users.did,
1198 tables.users.handle,
1199 tables.users.displayName,
1200 tables.users.avatar,
1201 )
1202 .orderBy(desc(tables.shouts.createdAt))
1203 .execute();
1204
1205 return c.json(shouts);
1206});
1207
1208app.get("/:did/app.rocksky.song/:rkey/shouts", async (c) => {
1209 requestCounter.add(1, {
1210 method: "GET",
1211 route: "/users/:did/app.rocksky.song/:rkey/shouts",
1212 });
1213 const did = c.req.param("did");
1214 const rkey = c.req.param("rkey");
1215
1216 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
1217
1218 let user;
1219 if (bearer && bearer !== "null") {
1220 const payload = jwt.verify(bearer, env.JWT_SECRET, {
1221 ignoreExpiration: true,
1222 });
1223
1224 user = await ctx.client.db.users
1225 .filter("did", equals(payload.did))
1226 .getFirst();
1227 }
1228
1229 const shouts = await ctx.db
1230 .select({
1231 shouts: user
1232 ? {
1233 id: tables.shouts.id,
1234 content: tables.shouts.content,
1235 createdAt: tables.shouts.createdAt,
1236 uri: tables.shouts.uri,
1237 parent: tables.shouts.parentId,
1238 likes: count(tables.shoutLikes.id).as("likes"),
1239 liked: sql<boolean>`
1240 EXISTS (
1241 SELECT 1
1242 FROM ${tables.shoutLikes}
1243 WHERE ${tables.shoutLikes}.shout_id = ${tables.shouts}.xata_id
1244 AND ${tables.shoutLikes}.user_id = ${user.xata_id}
1245 )`.as("liked"),
1246 }
1247 : {
1248 id: tables.shouts.id,
1249 content: tables.shouts.content,
1250 createdAt: tables.shouts.createdAt,
1251 uri: tables.shouts.uri,
1252 parent: tables.shouts.parentId,
1253 likes: count(tables.shoutLikes.id).as("likes"),
1254 },
1255 users: {
1256 id: tables.users.id,
1257 did: tables.users.did,
1258 handle: tables.users.handle,
1259 displayName: tables.users.displayName,
1260 avatar: tables.users.avatar,
1261 },
1262 })
1263 .from(tables.shouts)
1264 .leftJoin(tables.users, eq(tables.shouts.authorId, tables.users.id))
1265 .leftJoin(tables.tracks, eq(tables.shouts.trackId, tables.tracks.id))
1266 .leftJoin(
1267 tables.shoutLikes,
1268 eq(tables.shouts.id, tables.shoutLikes.shoutId),
1269 )
1270 .where(eq(tables.tracks.uri, `at://${did}/app.rocksky.song/${rkey}`))
1271 .groupBy(
1272 tables.shouts.id,
1273 tables.shouts.content,
1274 tables.shouts.createdAt,
1275 tables.shouts.uri,
1276 tables.shouts.parentId,
1277 tables.users.id,
1278 tables.users.did,
1279 tables.users.handle,
1280 tables.users.displayName,
1281 tables.users.avatar,
1282 )
1283 .orderBy(desc(tables.shouts.createdAt))
1284 .execute();
1285
1286 return c.json(shouts);
1287});
1288
1289app.get("/:did/app.rocksky.scrobble/:rkey/shouts", async (c) => {
1290 requestCounter.add(1, {
1291 method: "GET",
1292 route: "/users/:did/app.rocksky.scrobble/:rkey/shouts",
1293 });
1294 const did = c.req.param("did");
1295 const rkey = c.req.param("rkey");
1296
1297 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
1298
1299 let user;
1300 if (bearer && bearer !== "null") {
1301 const payload = jwt.verify(bearer, env.JWT_SECRET, {
1302 ignoreExpiration: true,
1303 });
1304
1305 user = await ctx.client.db.users
1306 .filter("did", equals(payload.did))
1307 .getFirst();
1308 }
1309
1310 const shouts = await ctx.db
1311 .select({
1312 shouts: user
1313 ? {
1314 id: tables.shouts.id,
1315 content: tables.shouts.content,
1316 createdAt: tables.shouts.createdAt,
1317 uri: tables.shouts.uri,
1318 parent: tables.shouts.parentId,
1319 likes: count(tables.shoutLikes.id).as("likes"),
1320 liked: sql<boolean>`
1321 EXISTS (
1322 SELECT 1
1323 FROM ${tables.shoutLikes}
1324 WHERE ${tables.shoutLikes}.shout_id = ${tables.shouts}.xata_id
1325 AND ${tables.shoutLikes}.user_id = ${user.xata_id}
1326 )`.as("liked"),
1327 }
1328 : {
1329 id: tables.shouts.id,
1330 content: tables.shouts.content,
1331 createdAt: tables.shouts.createdAt,
1332 uri: tables.shouts.uri,
1333 parent: tables.shouts.parentId,
1334 likes: count(tables.shoutLikes.id).as("likes"),
1335 },
1336 users: {
1337 id: tables.users.id,
1338 did: tables.users.did,
1339 handle: tables.users.handle,
1340 displayName: tables.users.displayName,
1341 avatar: tables.users.avatar,
1342 },
1343 })
1344 .from(tables.shouts)
1345 .leftJoin(tables.users, eq(tables.shouts.authorId, tables.users.id))
1346 .leftJoin(
1347 tables.scrobbles,
1348 eq(tables.shouts.scrobbleId, tables.scrobbles.id),
1349 )
1350 .leftJoin(
1351 tables.shoutLikes,
1352 eq(tables.shouts.id, tables.shoutLikes.shoutId),
1353 )
1354 .where(eq(tables.scrobbles.uri, `at://${did}/app.rocksky.scrobble/${rkey}`))
1355 .groupBy(
1356 tables.shouts.id,
1357 tables.shouts.content,
1358 tables.shouts.createdAt,
1359 tables.shouts.uri,
1360 tables.shouts.parentId,
1361 tables.users.id,
1362 tables.users.did,
1363 tables.users.handle,
1364 tables.users.displayName,
1365 tables.users.avatar,
1366 )
1367 .orderBy(desc(tables.shouts.createdAt))
1368 .execute();
1369
1370 return c.json(shouts);
1371});
1372
1373app.get("/:did/shouts", async (c) => {
1374 requestCounter.add(1, { method: "GET", route: "/users/:did/shouts" });
1375 const did = c.req.param("did");
1376
1377 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
1378
1379 let user;
1380 if (bearer && bearer !== "null") {
1381 const payload = jwt.verify(bearer, env.JWT_SECRET, {
1382 ignoreExpiration: true,
1383 });
1384
1385 user = await ctx.client.db.users
1386 .filter("did", equals(payload.did))
1387 .getFirst();
1388 }
1389
1390 const shouts = await ctx.db
1391 .select({
1392 profileShouts: {
1393 id: tables.profileShouts.id,
1394 createdAt: tables.profileShouts.createdAt,
1395 },
1396 shouts: user
1397 ? {
1398 id: tables.shouts.id,
1399 content: tables.shouts.content,
1400 createdAt: tables.shouts.createdAt,
1401 uri: tables.shouts.uri,
1402 parent: tables.shouts.parentId,
1403 likes: count(tables.shoutLikes.id).as("likes"),
1404 liked: sql<boolean>`
1405 EXISTS (
1406 SELECT 1
1407 FROM ${tables.shoutLikes}
1408 WHERE ${tables.shoutLikes}.shout_id = ${tables.shouts}.xata_id
1409 AND ${tables.shoutLikes}.user_id = ${user.xata_id}
1410 )`.as("liked"),
1411 reported: sql<boolean>`
1412 EXISTS (
1413 SELECT 1
1414 FROM ${tables.shoutReports}
1415 WHERE ${tables.shoutReports}.shout_id = ${tables.shouts}.xata_id
1416 AND ${tables.shoutReports}.user_id = ${user.xata_id}
1417 )`.as("reported"),
1418 }
1419 : {
1420 id: tables.shouts.id,
1421 content: tables.shouts.content,
1422 createdAt: tables.shouts.createdAt,
1423 uri: tables.shouts.uri,
1424 parent: tables.shouts.parentId,
1425 likes: count(tables.shoutLikes.id).as("likes"),
1426 },
1427 users: {
1428 id: aliasedTable(tables.users, "authors").id,
1429 did: aliasedTable(tables.users, "authors").did,
1430 handle: aliasedTable(tables.users, "authors").handle,
1431 displayName: aliasedTable(tables.users, "authors").displayName,
1432 avatar: aliasedTable(tables.users, "authors").avatar,
1433 },
1434 })
1435 .from(tables.profileShouts)
1436 .where(or(eq(tables.users.did, did), eq(tables.users.handle, did)))
1437 .leftJoin(tables.shouts, eq(tables.profileShouts.shoutId, tables.shouts.id))
1438 .leftJoin(
1439 aliasedTable(tables.users, "authors"),
1440 eq(tables.shouts.authorId, aliasedTable(tables.users, "authors").id),
1441 )
1442 .leftJoin(tables.users, eq(tables.profileShouts.userId, tables.users.id))
1443 .leftJoin(
1444 tables.shoutLikes,
1445 eq(tables.shouts.id, tables.shoutLikes.shoutId),
1446 )
1447 .groupBy(
1448 tables.profileShouts.id,
1449 tables.profileShouts.createdAt,
1450 tables.shouts.id,
1451 tables.shouts.uri,
1452 tables.shouts.content,
1453 tables.shouts.createdAt,
1454 tables.shouts.parentId,
1455 tables.users.id,
1456 tables.users.did,
1457 tables.users.handle,
1458 tables.users.displayName,
1459 tables.users.avatar,
1460 aliasedTable(tables.users, "authors").id,
1461 aliasedTable(tables.users, "authors").did,
1462 aliasedTable(tables.users, "authors").handle,
1463 aliasedTable(tables.users, "authors").displayName,
1464 aliasedTable(tables.users, "authors").avatar,
1465 )
1466 .orderBy(desc(tables.profileShouts.createdAt))
1467 .execute();
1468
1469 return c.json(shouts);
1470});
1471
1472app.get("/:did/app.rocksky.shout/:rkey/likes", async (c) => {
1473 requestCounter.add(1, {
1474 method: "GET",
1475 route: "/users/:did/app.rocksky.shout/:rkey/likes",
1476 });
1477 const did = c.req.param("did");
1478 const rkey = c.req.param("rkey");
1479 const likes = await ctx.client.db.shout_likes
1480 .select(["user_id.*", "xata_createdat"])
1481 .filter("shout_id.uri", `at://${did}/app.rocksky.shout/${rkey}`)
1482 .getAll();
1483 return c.json(likes);
1484});
1485
1486app.get("/:did/app.rocksky.shout/:rkey/replies", async (c) => {
1487 requestCounter.add(1, {
1488 method: "GET",
1489 route: "/users/:did/app.rocksky.shout/:rkey/replies",
1490 });
1491 const did = c.req.param("did");
1492 const rkey = c.req.param("rkey");
1493 const shouts = await ctx.client.db.shouts
1494 .select(["author_id.*", "xata_createdat"])
1495 .filter("parent_id.uri", `at://${did}/app.rocksky.shout/${rkey}`)
1496 .sort("xata_createdat", "asc")
1497 .getAll();
1498 return c.json(shouts);
1499});
1500
1501app.get("/:did/stats", async (c) => {
1502 requestCounter.add(1, { method: "GET", route: "/users/:did/stats" });
1503 const did = c.req.param("did");
1504
1505 const { data } = await ctx.analytics.post("library.getStats", {
1506 user_did: did,
1507 });
1508
1509 return c.json({
1510 scrobbles: data.scrobbles,
1511 artists: data.artists,
1512 lovedTracks: data.loved_tracks,
1513 albums: data.albums,
1514 tracks: data.tracks,
1515 });
1516});
1517
1518app.post("/:did/app.rocksky.shout/:rkey/report", async (c) => {
1519 requestCounter.add(1, {
1520 method: "POST",
1521 route: "/users/:did/app.rocksky.shout/:rkey/report",
1522 });
1523 const did = c.req.param("did");
1524 const rkey = c.req.param("rkey");
1525
1526 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
1527
1528 if (!bearer || bearer === "null") {
1529 c.status(401);
1530 return c.text("Unauthorized");
1531 }
1532
1533 const payload = jwt.verify(bearer, env.JWT_SECRET, {
1534 ignoreExpiration: true,
1535 });
1536 const shout = await ctx.client.db.shouts
1537 .filter("uri", `at://${did}/app.rocksky.shout/${rkey}`)
1538 .getFirst();
1539
1540 const user = await ctx.client.db.users
1541 .filter("did", equals(payload.did))
1542 .getFirst();
1543
1544 if (!shout) {
1545 c.status(404);
1546 return c.text("Shout not found");
1547 }
1548
1549 if (!user) {
1550 c.status(401);
1551 return c.text("Unauthorized");
1552 }
1553
1554 const existingReport = await ctx.client.db.shout_reports
1555 .filter({
1556 user_id: user.xata_id,
1557 shout_id: shout.xata_id,
1558 })
1559 .getFirst();
1560
1561 if (existingReport) {
1562 return c.json(existingReport);
1563 }
1564
1565 const report = await ctx.client.db.shout_reports.create({
1566 user_id: user.xata_id,
1567 shout_id: shout.xata_id,
1568 });
1569
1570 return c.json(report);
1571});
1572
1573app.delete("/:did/app.rocksky.shout/:rkey/report", async (c) => {
1574 requestCounter.add(1, {
1575 method: "DELETE",
1576 route: "/users/:did/app.rocksky.shout/:rkey/report",
1577 });
1578 const did = c.req.param("did");
1579 const rkey = c.req.param("rkey");
1580
1581 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
1582
1583 if (!bearer || bearer === "null") {
1584 c.status(401);
1585 return c.text("Unauthorized");
1586 }
1587
1588 const payload = jwt.verify(bearer, env.JWT_SECRET, {
1589 ignoreExpiration: true,
1590 });
1591 const shout = await ctx.client.db.shouts
1592 .filter("uri", `at://${did}/app.rocksky.shout/${rkey}`)
1593 .getFirst();
1594
1595 const user = await ctx.client.db.users
1596 .filter("did", equals(payload.did))
1597 .getFirst();
1598
1599 if (!shout) {
1600 c.status(404);
1601 return c.text("Shout not found");
1602 }
1603
1604 if (!user) {
1605 c.status(401);
1606 return c.text("Unauthorized");
1607 }
1608
1609 const report = await ctx.client.db.shout_reports
1610 .select(["user_id.*", "shout_id.*"])
1611 .filter({
1612 user_id: user.xata_id,
1613 shout_id: shout.xata_id,
1614 })
1615 .getFirst();
1616
1617 if (!report) {
1618 c.status(404);
1619 return c.text("Report not found");
1620 }
1621
1622 if (report.user_id.xata_id !== user.xata_id) {
1623 c.status(403);
1624 return c.text("Forbidden");
1625 }
1626
1627 await ctx.client.db.shout_reports.delete(report.xata_id);
1628
1629 return c.json(report);
1630});
1631
1632app.delete("/:did/app.rocksky.shout/:rkey", async (c) => {
1633 requestCounter.add(1, {
1634 method: "DELETE",
1635 route: "/users/:did/app.rocksky.shout/:rkey",
1636 });
1637 const did = c.req.param("did");
1638 const rkey = c.req.param("rkey");
1639
1640 const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
1641
1642 if (!bearer || bearer === "null") {
1643 c.status(401);
1644 return c.text("Unauthorized");
1645 }
1646
1647 const payload = jwt.verify(bearer, env.JWT_SECRET, {
1648 ignoreExpiration: true,
1649 });
1650 const agent = await createAgent(ctx.oauthClient, payload.did);
1651
1652 const user = await ctx.client.db.users
1653 .filter("did", equals(payload.did))
1654 .getFirst();
1655
1656 if (!user) {
1657 c.status(401);
1658 return c.text("Unauthorized");
1659 }
1660
1661 const shout = await ctx.client.db.shouts
1662 .select([
1663 "author_id.*",
1664 "uri",
1665 "content",
1666 "xata_id",
1667 "xata_createdat",
1668 "parent_id",
1669 ])
1670 .filter("uri", `at://${did}/app.rocksky.shout/${rkey}`)
1671 .getFirst();
1672
1673 if (!shout) {
1674 c.status(404);
1675 return c.text("Shout not found");
1676 }
1677
1678 if (shout.author_id.xata_id !== user.xata_id) {
1679 c.status(403);
1680 return c.text("Forbidden");
1681 }
1682
1683 const replies = await ctx.db
1684 .select({
1685 replies: {
1686 id: tables.shouts.id,
1687 },
1688 })
1689 .from(tables.shouts)
1690 .where(eq(tables.shouts.parentId, shout.xata_id))
1691 .execute();
1692
1693 const replyIds = replies.map(({ replies: r }) => r.id);
1694
1695 // Delete related records in the correct order
1696 await ctx.db
1697 .delete(tables.shoutLikes)
1698 .where(inArray(tables.shoutLikes.shoutId, replyIds))
1699 .execute();
1700
1701 await ctx.db
1702 .delete(tables.shoutReports)
1703 .where(inArray(tables.shoutReports.shoutId, replyIds))
1704 .execute();
1705
1706 await ctx.db
1707 .delete(tables.profileShouts)
1708 .where(eq(tables.profileShouts.shoutId, shout.xata_id))
1709 .execute();
1710
1711 await ctx.db
1712 .delete(tables.profileShouts)
1713 .where(inArray(tables.profileShouts.shoutId, replyIds))
1714 .execute();
1715
1716 await ctx.db
1717 .delete(tables.shoutLikes)
1718 .where(eq(tables.shoutLikes.shoutId, shout.xata_id))
1719 .execute();
1720
1721 await ctx.db
1722 .delete(tables.shoutReports)
1723 .where(eq(tables.shoutReports.shoutId, shout.xata_id))
1724 .execute();
1725
1726 await ctx.db
1727 .delete(tables.shouts)
1728 .where(inArray(tables.shouts.id, replyIds))
1729 .execute();
1730
1731 await ctx.db
1732 .delete(tables.shouts)
1733 .where(eq(tables.shouts.id, shout.xata_id))
1734 .execute();
1735
1736 await agent.com.atproto.repo.deleteRecord({
1737 repo: agent.assertDid,
1738 collection: "app.rocksky.shout",
1739 rkey: shout.uri.split("/").pop(),
1740 });
1741
1742 return c.json(shout);
1743});
1744
1745export default app;