A decentralized music tracking and discovery platform built on AT Protocol 馃幍
at fix/spotify 1745 lines 46 kB view raw
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;