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