Something went wrong. Try again.
A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904import { ctx } from "context";import { aliasedTable, and, asc, count, desc, eq, inArray, or, sql,} from "drizzle-orm";import { Hono } from "hono";import { createAgent } from "lib/agent";import { fetchBskyProfile } from "lib/bskyProfile";import { withLikes } from "lib/trackLikes";import { verifyToken } from "lib/verifyToken";import { likeTrack, unLikeTrack } from "lovedtracks/lovedtracks.service";import { requestCounter } from "metrics";import * as R from "ramda";import tables from "schema";import type { SelectUser } from "schema/users";import { createShout, likeShout, replyShout, unlikeShout,} from "shouts/shouts.service";import { shoutSchema } from "types/shout";import type { Track } from "types/track";import { dedupeTracksKeepLyrics } from "./utils";
const app = new Hono();
/** Shared media (gif/sticker/clip) + mention-facet columns selected alongside * every shout row. */const shoutGifColumns = { gifUrl: tables.shouts.gifUrl, gifPreviewUrl: tables.shouts.gifPreviewUrl, gifAlt: tables.shouts.gifAlt, gifWidth: tables.shouts.gifWidth, gifHeight: tables.shouts.gifHeight, facets: tables.shouts.facets,};
/** The same columns as `shoutGifColumns`, for spreading into a `groupBy()` — * Postgres requires every non-aggregated selected column to appear here. */const shoutGifGroupBy = [ tables.shouts.gifUrl, tables.shouts.gifPreviewUrl, tables.shouts.gifAlt, tables.shouts.gifWidth, tables.shouts.gifHeight, tables.shouts.facets,];
app.get("/:did/likes", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/likes" }); const did = c.req.param("did"); const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const lovedTracks = await ctx.db .select() .from(tables.lovedTracks) .leftJoin(tables.tracks, eq(tables.lovedTracks.trackId, tables.tracks.id)) .leftJoin(tables.users, eq(tables.lovedTracks.userId, tables.users.id)) .where(or(eq(tables.users.did, did), eq(tables.users.handle, did))) .orderBy(desc(tables.lovedTracks.createdAt)) .limit(size) .offset(offset) .execute();
return c.json(lovedTracks);});
app.get("/:handle/scrobbles", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:handle/scrobbles" }); const handle = c.req.param("handle");
const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const { data } = await ctx.analytics.post("library.getScrobbles", { user_did: handle, pagination: { skip: offset, take: size, }, });
return c.json(data);});
app.get("/:did/albums", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/albums" }); const did = c.req.param("did"); const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const { data } = await ctx.analytics.post("library.getTopAlbums", { user_did: did, pagination: { skip: offset, take: size, }, });
return c.json(data.map((item) => ({ ...item, tags: [] })));});
app.get("/:did/artists", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/artists" }); const did = c.req.param("did"); const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const { data } = await ctx.analytics.post("library.getTopArtists", { user_did: did, pagination: { skip: offset, take: size, }, });
return c.json(data.map((item) => ({ ...item, tags: [] })));});
app.get("/:did/tracks", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/tracks" }); const did = c.req.param("did"); const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const { data } = await ctx.analytics.post("library.getTopTracks", { user_did: did, pagination: { skip: offset, take: size, }, });
return c.json( data.map((item) => ({ ...item, tags: [], })), );});
app.get("/:did/playlists", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/playlists" }); const did = c.req.param("did"); const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const results = await ctx.db .select({ playlists: tables.playlists, trackCount: sql<number>` (SELECT COUNT(*) FROM ${tables.playlistTracks} WHERE ${tables.playlistTracks.playlistId} = ${tables.playlists.id} )`.as("trackCount"), }) .from(tables.playlists) .leftJoin(tables.users, eq(tables.playlists.createdBy, tables.users.id)) .where(or(eq(tables.users.did, did), eq(tables.users.handle, did))) .offset(offset) .limit(size) .execute();
return c.json( results.map((x) => ({ ...x.playlists, trackCount: +x.trackCount, })), );});
app.get("/:did/app.rocksky.scrobble/:rkey", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.scrobble/:rkey", }); const did = c.req.param("did"); const rkey = c.req.param("rkey"); const uri = `at://${did}/app.rocksky.scrobble/${rkey}`;
const scrobble = await ctx.db .select() .from(tables.scrobbles) .leftJoin(tables.tracks, eq(tables.scrobbles.trackId, tables.tracks.id)) .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)) .where(eq(tables.scrobbles.uri, uri)) .limit(1) .execute() .then((rows) => rows[0]);
if (!scrobble) { c.status(404); return c.text("Scrobble not found"); }
const [listeners, scrobbles] = await Promise.all([ ctx.db .select({ count: sql<number>`COUNT(*)` }) .from(tables.userTracks) .where(eq(tables.userTracks.trackId, scrobble.tracks.id)) .execute() .then((rows) => rows[0].count), ctx.db .select({ count: sql<number>`COUNT(*)` }) .from(tables.scrobbles) .where(eq(tables.scrobbles.trackId, scrobble.tracks.id)) .execute() .then((rows) => rows[0].count), ]);
return c.json({ ...scrobble, id: scrobble.scrobbles.id, listeners: listeners || 1, scrobbles: scrobbles || 1, tags: [], });});
app.get("/:did/app.rocksky.artist/:rkey", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.artist/:rkey", }); const did = c.req.param("did"); const rkey = c.req.param("rkey"); const uri = `at://${did}/app.rocksky.artist/${rkey}`;
const artist = await ctx.db .select() .from(tables.userArtists) .leftJoin( tables.artists, eq(tables.userArtists.artistId, tables.artists.id), ) .where(or(eq(tables.userArtists.uri, uri), eq(tables.artists.uri, uri))) .limit(1) .execute() .then((rows) => rows[0]);
if (!artist) { c.status(404); return c.text("Artist not found"); }
const [listeners, scrobbles] = await Promise.all([ ctx.db .select({ count: sql<number>`COUNT(*)` }) .from(tables.userArtists) .where(eq(tables.userArtists.artistId, artist.artists.id)) .execute() .then((rows) => rows[0].count), ctx.db .select({ count: sql<number>`COUNT(*)` }) .from(tables.scrobbles) .where(eq(tables.scrobbles.artistId, artist.artists.id)) .execute() .then((rows) => rows[0].count), ]);
return c.json({ ...R.omit(["id"], artist.artists), id: artist.artists.id, listeners: listeners || 1, scrobbles: scrobbles || 1, tags: [], });});
app.get("/:did/app.rocksky.album/:rkey", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.album/:rkey", });
const did = c.req.param("did"); const rkey = c.req.param("rkey"); const uri = `at://${did}/app.rocksky.album/${rkey}`;
const album = await ctx.db .select() .from(tables.userAlbums) .leftJoin(tables.albums, eq(tables.userAlbums.albumId, tables.albums.id)) .where(or(eq(tables.userAlbums.uri, uri), eq(tables.albums.uri, uri))) .limit(1) .execute() .then((rows) => rows[0]);
if (!album) { c.status(404); return c.text("Album not found"); }
const tracks = await ctx.db .select() .from(tables.albumTracks) .leftJoin(tables.tracks, eq(tables.albumTracks.trackId, tables.tracks.id)) .where(eq(tables.albumTracks.albumId, album.albums.id)) .orderBy(asc(tables.tracks.trackNumber)) .execute();
const [listeners, scrobbles] = await Promise.all([ ctx.db .select({ count: sql<number>`COUNT(*)` }) .from(tables.userAlbums) .where(eq(tables.userAlbums.albumId, album.albums.id)) .execute() .then((rows) => rows[0].count), ctx.db .select({ count: sql<number>`COUNT(*)` }) .from(tables.scrobbles) .where(eq(tables.scrobbles.albumId, album.albums.id)) .execute() .then((rows) => rows[0].count), ]);
return c.json({ ...R.omit(["id"], album.albums), id: album.albums.id, listeners: listeners || 1, scrobbles: scrobbles || 1, label: tracks[0]?.tracks.label || "", tracks: dedupeTracksKeepLyrics(tracks.map((track) => track.tracks)).sort( (a, b) => a.track_number - b.track_number, ), tags: [], });});
app.get("/:did/app.rocksky.song/:rkey", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.song/:rkey", }); const did = c.req.param("did"); const rkey = c.req.param("rkey"); const uri = `at://${did}/app.rocksky.song/${rkey}`;
const [_track, user_track] = await Promise.all([ ctx.db .select() .from(tables.tracks) .where(eq(tables.tracks.uri, uri)) .limit(1) .execute() .then((rows) => rows[0]), ctx.db .select() .from(tables.userTracks) .leftJoin(tables.tracks, eq(tables.userTracks.trackId, tables.tracks.id)) .where(eq(tables.userTracks.uri, uri)) .limit(1) .execute() .then((rows) => rows[0]), ]); const track = _track || user_track?.tracks;
if (!track) { c.status(404); return c.text("Track not found"); }
const [listeners, scrobbles] = await Promise.all([ ctx.db .select({ count: sql<number>`COUNT(*)` }) .from(tables.userTracks) .where(eq(tables.userTracks.trackId, track.id)) .execute() .then((rows) => rows[0].count), ctx.db .select({ count: sql<number>`COUNT(*)` }) .from(tables.scrobbles) .where(eq(tables.scrobbles.trackId, track.id)) .execute() .then((rows) => rows[0].count), ]);
return c.json({ ...R.omit(["id"], track), id: track.id, tags: [], listeners: listeners || 1, scrobbles: scrobbles || 1, });});
app.get("/:did/app.rocksky.artist/:rkey/tracks", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.artist/:rkey/tracks", }); const did = c.req.param("did"); const rkey = c.req.param("rkey"); const uri = `at://${did}/app.rocksky.artist/${rkey}`; const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const tracks = await ctx.db .select() .from(tables.artistTracks) .leftJoin(tables.tracks, eq(tables.artistTracks.trackId, tables.tracks.id)) .where(eq(tables.artistTracks.artistId, uri)) // Assuming artist_id is the URI or ID; adjust if needed .orderBy(desc(tables.artistTracks.xataVersion)) .limit(size) .offset(offset) .execute();
return c.json( tracks.map((item) => ({ ...R.omit(["id"], item.tracks), id: item.tracks.id, xata_version: item.artist_tracks.xataVersion, })), );});
app.get("/:did/app.rocksky.artist/:rkey/albums", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.artist/:rkey/albums", }); const did = c.req.param("did"); const rkey = c.req.param("rkey"); const uri = `at://${did}/app.rocksky.artist/${rkey}`; const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0;
const albums = await ctx.db .select() .from(tables.artistAlbums) .leftJoin(tables.albums, eq(tables.artistAlbums.albumId, tables.albums.id)) .where(eq(tables.artistAlbums.artistId, uri)) // Assuming artist_id is the URI or ID; adjust if needed .orderBy(desc(tables.artistAlbums.xataVersion)) .limit(size) .offset(offset) .execute();
return c.json( R.uniqBy( (item) => item.id, albums.map((item) => ({ ...R.omit(["id"], item.albums), id: item.albums.id, xata_version: item.artist_albums.xataVersion, })), ), );});
app.get("/:did/app.rocksky.playlist/:rkey", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.playlist/:rkey", }); const did = c.req.param("did"); const rkey = c.req.param("rkey"); const uri = `at://${did}/app.rocksky.playlist/${rkey}`;
const playlist = await ctx.db .select() .from(tables.playlists) .leftJoin(tables.users, eq(tables.playlists.createdBy, tables.users.id)) .where(eq(tables.playlists.uri, uri)) .execute();
if (!playlist.length) { c.status(404); return c.text("Playlist not found"); }
const results = await ctx.db .select() .from(tables.playlistTracks) .leftJoin( tables.playlists, eq(tables.playlistTracks.playlistId, tables.playlists.id), ) .leftJoin( tables.tracks, eq(tables.playlistTracks.trackId, tables.tracks.id), ) .where(eq(tables.playlists.uri, uri)) // Dropped a GROUP BY that listed all 41 selected columns by hand. There are // no aggregates and each entry joins one playlist and one track, so it // de-duplicated nothing — and since these tables have no PRIMARY KEY (Xata // uses unique indexes), Postgres can't infer functional dependency, so every // column added to the schema later broke the query. .orderBy(asc(tables.playlistTracks.createdAt)) .execute();
// Optional auth: the playlist is public, a valid token only decides whether // `liked` can be filled in. A bad token must not fail the read. let did_: string | undefined; const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); if (bearer && bearer !== "null") { try { did_ = (await verifyToken(bearer)).did; } catch { did_ = undefined; } }
return c.json({ ...playlist[0].playlists, curatedBy: playlist[0].users, tracks: await withLikes( ctx, results.map((x) => x.tracks), did_, ), });});
app.get("/:did", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did" }); const did = c.req.param("did"); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); if (bearer && bearer !== "null") { const claims = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, claims.did);
if (agent) { const handle = await ctx.resolver.resolveDidToHandle(did); const resolved = await fetchBskyProfile(did, agent);
if (handle) { await ctx.db .update(tables.users) .set({ handle, // Only overwrite avatar/displayName when the lookup actually // returned them, so a failed/partial fetch never clobbers good data. ...(resolved.displayName !== undefined ? { displayName: resolved.displayName } : {}), ...(resolved.avatar !== undefined ? { avatar: resolved.avatar } : {}), }) .where(eq(tables.users.did, did)) .execute();
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, did)) .limit(1) .execute() .then((rows) => rows[0]);
ctx.nc.publish("rocksky.user", Buffer.from(JSON.stringify(user))); } } }
const user = await ctx.db .select() .from(tables.users) .where(or(eq(tables.users.did, did), eq(tables.users.handle, did))) .limit(1) .execute() .then((rows) => rows[0]);
if (!user) { c.status(404); return c.text("User not found"); }
return c.json(user);});
app.post("/:did/app.rocksky.artist/:rkey/shouts", async (c) => { requestCounter.add(1, { method: "POST", route: "/users/:did/app.rocksky.artist/:rkey/shouts", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const did = c.req.param("did"); const rkey = c.req.param("rkey"); const body = await c.req.json(); const parsed = shoutSchema.safeParse(body);
if (parsed.error) { c.status(400); return c.text("Invalid shout data: " + parsed.error.message); }
await createShout( ctx, parsed.data, `at://${did}/app.rocksky.artist/${rkey}`, user, agent, ); return c.json({});});
app.post("/:did/app.rocksky.album/:rkey/shouts", async (c) => { requestCounter.add(1, { method: "POST", route: "/users/:did/app.rocksky.album/:rkey/shouts", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const did = c.req.param("did"); const rkey = c.req.param("rkey"); const body = await c.req.json(); const parsed = shoutSchema.safeParse(body);
if (parsed.error) { c.status(400); return c.text("Invalid shout data: " + parsed.error.message); }
await createShout( ctx, parsed.data, `at://${did}/app.rocksky.album/${rkey}`, user, agent, ); return c.json({});});
app.post("/:did/app.rocksky.song/:rkey/shouts", async (c) => { requestCounter.add(1, { method: "POST", route: "/users/:did/app.rocksky.song/:rkey/shouts", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const did = c.req.param("did"); const rkey = c.req.param("rkey"); const body = await c.req.json();
const parsed = shoutSchema.safeParse(body); if (parsed.error) { c.status(400); return c.text("Invalid shout data: " + parsed.error.message); }
await createShout( ctx, parsed.data, `at://${did}/app.rocksky.song/${rkey}`, user, agent, );
return c.json({});});
app.post("/:did/app.rocksky.scrobble/:rkey/shouts", async (c) => { requestCounter.add(1, { method: "POST", route: "/users/:did/app.rocksky.scrobble/:rkey/shouts", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const did = c.req.param("did"); const rkey = c.req.param("rkey"); const body = await c.req.json();
const parsed = shoutSchema.safeParse(body); if (parsed.error) { c.status(400); return c.text("Invalid shout data: " + parsed.error.message); }
await createShout( ctx, parsed.data, `at://${did}/app.rocksky.scrobble/${rkey}`, user, agent, );
return c.json({});});
app.post("/:did/shouts", async (c) => { requestCounter.add(1, { method: "POST", route: "/users/:did/shouts" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const did = c.req.param("did"); const body = await c.req.json(); const parsed = shoutSchema.safeParse(body); if (parsed.error) { c.status(400); return c.text("Invalid shout data: " + parsed.error.message); }
const _user = await ctx.db .select() .from(tables.users) .where(or(eq(tables.users.did, did), eq(tables.users.handle, did))) .limit(1) .execute() .then((rows) => rows[0]);
if (!_user) { c.status(404); return c.text("User not found"); }
await createShout(ctx, parsed.data, `at://${_user.did}`, user, agent);
return c.json({});});
app.post("/:did/app.rocksky.shout/:rkey/likes", async (c) => { requestCounter.add(1, { method: "POST", route: "/users/:did/app.rocksky.shout/:rkey/likes", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const did = c.req.param("did"); const rkey = c.req.param("rkey"); await likeShout(ctx, `at://${did}/app.rocksky.shout/${rkey}`, user, agent);
return c.json({});});
app.delete("/:did/app.rocksky.shout/:rkey/likes", async (c) => { requestCounter.add(1, { method: "DELETE", route: "/users/:did/app.rocksky.shout/:rkey/likes", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const did = c.req.param("did"); const rkey = c.req.param("rkey");
await unlikeShout(ctx, `at://${did}/app.rocksky.shout/${rkey}`, user, agent);
return c.json({});});
app.post("/:did/app.rocksky.song/:rkey/likes", async (c) => { requestCounter.add(1, { method: "POST", route: "/users/:did/app.rocksky.song/:rkey/likes", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const did = c.req.param("did"); const rkey = c.req.param("rkey");
const result = await ctx.db .select() .from(tables.tracks) .where(eq(tables.tracks.uri, `at://${did}/app.rocksky.song/${rkey}`)) .limit(1) .execute() .then((rows) => rows[0]);
const track: Track = { title: result.title, artist: result.artist, album: result.album, albumArt: result.albumArt, albumArtist: result.albumArtist, trackNumber: result.trackNumber, duration: result.duration, composer: result.composer, lyrics: result.lyrics, discNumber: result.discNumber, }; await likeTrack(ctx, track, user, agent);
return c.json({});});
app.delete("/:did/app.rocksky.song/:rkey/likes", async (c) => { requestCounter.add(1, { method: "DELETE", route: "/users/:did/app.rocksky.song/:rkey/likes", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const did = c.req.param("did"); const rkey = c.req.param("rkey");
const track = await ctx.db .select() .from(tables.tracks) .where(eq(tables.tracks.uri, `at://${did}/app.rocksky.song/${rkey}`)) .limit(1) .execute() .then((rows) => rows[0]);
if (!track) { c.status(404); return c.text("Track not found"); }
await unLikeTrack(ctx, track.sha256, user, agent);
return c.json([]);});
// Like by track id, for rows that have no app.rocksky.song record to address.// A track ingested from a scrobble carries uri = NULL until it is published,// and the like itself is keyed by sha256 anyway — the song URI was only ever a// way to find the row.app.post("/tracks/:trackId/likes", async (c) => { requestCounter.add(1, { method: "POST", route: "/users/tracks/:trackId/likes", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did); const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const result = await ctx.db .select() .from(tables.tracks) .where(eq(tables.tracks.id, c.req.param("trackId"))) .limit(1) .execute() .then((rows) => rows[0]); if (!result) { c.status(404); return c.text("Track not found"); }
await likeTrack( ctx, { title: result.title, artist: result.artist, album: result.album, albumArt: result.albumArt, albumArtist: result.albumArtist, trackNumber: result.trackNumber, duration: result.duration, composer: result.composer, lyrics: result.lyrics, discNumber: result.discNumber, }, user, agent, );
return c.json({});});
app.delete("/tracks/:trackId/likes", async (c) => { requestCounter.add(1, { method: "DELETE", route: "/users/tracks/:trackId/likes", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did); const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const track = await ctx.db .select() .from(tables.tracks) .where(eq(tables.tracks.id, c.req.param("trackId"))) .limit(1) .execute() .then((rows) => rows[0]); if (!track) { c.status(404); return c.text("Track not found"); }
await unLikeTrack(ctx, track.sha256, user, agent);
return c.json([]);});
app.post("/:did/app.rocksky.shout/:rkey/replies", async (c) => { requestCounter.add(1, { method: "POST", route: "/users/:did/app.rocksky.shout/:rkey/replies", }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); }
const did = c.req.param("did"); const rkey = c.req.param("rkey");
const body = await c.req.json(); const parsed = shoutSchema.safeParse(body);
if (parsed.error) { c.status(400); return c.text("Invalid shout data: " + parsed.error.message); }
await replyShout( ctx, parsed.data, `at://${did}/app.rocksky.shout/${rkey}`, user, agent, ); return c.json({});});
app.get("/:did/app.rocksky.artist/:rkey/shouts", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.artist/:rkey/shouts", }); const did = c.req.param("did"); const rkey = c.req.param("rkey");
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
let user: SelectUser | undefined; if (bearer && bearer !== "null") { const payload = await verifyToken(bearer);
user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); }
const shouts = await ctx.db .select({ shouts: user ? { id: tables.shouts.id, content: tables.shouts.content, ...shoutGifColumns, createdAt: tables.shouts.createdAt, uri: tables.shouts.uri, parent: tables.shouts.parentId, likes: count(tables.shoutLikes.id).as("likes"), liked: sql<boolean>` EXISTS ( SELECT 1 FROM ${tables.shoutLikes} WHERE ${tables.shoutLikes}.shout_id = ${tables.shouts}.xata_id AND ${tables.shoutLikes}.user_id = ${user.id} )`.as("liked"), } : { id: tables.shouts.id, content: tables.shouts.content, ...shoutGifColumns, createdAt: tables.shouts.createdAt, parent: tables.shouts.parentId, uri: tables.shouts.uri, likes: count(tables.shoutLikes.id).as("likes"), }, users: { id: tables.users.id, did: tables.users.did, handle: tables.users.handle, displayName: tables.users.displayName, avatar: tables.users.avatar, }, }) .from(tables.shouts) .leftJoin(tables.users, eq(tables.shouts.authorId, tables.users.id)) .leftJoin(tables.artists, eq(tables.shouts.artistId, tables.artists.id)) .leftJoin( tables.shoutLikes, eq(tables.shouts.id, tables.shoutLikes.shoutId), ) .where(eq(tables.artists.uri, `at://${did}/app.rocksky.artist/${rkey}`)) .groupBy( tables.shouts.id, tables.shouts.content, tables.shouts.createdAt, tables.shouts.uri, tables.shouts.parentId, ...shoutGifGroupBy, tables.users.id, tables.users.did, tables.users.handle, tables.users.displayName, tables.users.avatar, ) .orderBy(desc(tables.shouts.createdAt)) .execute(); return c.json(shouts);});
app.get("/:did/app.rocksky.album/:rkey/shouts", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.album/:rkey/shouts", });
const did = c.req.param("did"); const rkey = c.req.param("rkey");
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
let user: SelectUser | undefined; if (bearer && bearer !== "null") { const payload = await verifyToken(bearer);
user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); }
const shouts = await ctx.db .select({ shouts: user ? { id: tables.shouts.id, content: tables.shouts.content, ...shoutGifColumns, createdAt: tables.shouts.createdAt, parent: tables.shouts.parentId, uri: tables.shouts.uri, likes: count(tables.shoutLikes.id).as("likes"), liked: sql<boolean>` EXISTS ( SELECT 1 FROM ${tables.shoutLikes} WHERE ${tables.shoutLikes}.shout_id = ${tables.shouts}.xata_id AND ${tables.shoutLikes}.user_id = ${user.id} )`.as("liked"), } : { id: tables.shouts.id, content: tables.shouts.content, ...shoutGifColumns, createdAt: tables.shouts.createdAt, parent: tables.shouts.parentId, uri: tables.shouts.uri, likes: count(tables.shoutLikes.id).as("likes"), }, users: { id: tables.users.id, did: tables.users.did, handle: tables.users.handle, displayName: tables.users.displayName, avatar: tables.users.avatar, }, }) .from(tables.shouts) .leftJoin(tables.users, eq(tables.shouts.authorId, tables.users.id)) .leftJoin(tables.albums, eq(tables.shouts.albumId, tables.albums.id)) .leftJoin( tables.shoutLikes, eq(tables.shouts.id, tables.shoutLikes.shoutId), ) .where(eq(tables.albums.uri, `at://${did}/app.rocksky.album/${rkey}`)) .groupBy( tables.shouts.id, tables.shouts.content, tables.shouts.createdAt, tables.shouts.uri, tables.shouts.parentId, ...shoutGifGroupBy, tables.users.id, tables.users.did, tables.users.handle, tables.users.displayName, tables.users.avatar, ) .orderBy(desc(tables.shouts.createdAt)) .execute();
return c.json(shouts);});
app.get("/:did/app.rocksky.song/:rkey/shouts", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.song/:rkey/shouts", }); const did = c.req.param("did"); const rkey = c.req.param("rkey");
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
let user: SelectUser | undefined; if (bearer && bearer !== "null") { const payload = await verifyToken(bearer);
user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); }
const shouts = await ctx.db .select({ shouts: user ? { id: tables.shouts.id, content: tables.shouts.content, ...shoutGifColumns, createdAt: tables.shouts.createdAt, uri: tables.shouts.uri, parent: tables.shouts.parentId, likes: count(tables.shoutLikes.id).as("likes"), liked: sql<boolean>` EXISTS ( SELECT 1 FROM ${tables.shoutLikes} WHERE ${tables.shoutLikes}.shout_id = ${tables.shouts}.xata_id AND ${tables.shoutLikes}.user_id = ${user.id} )`.as("liked"), } : { id: tables.shouts.id, content: tables.shouts.content, ...shoutGifColumns, createdAt: tables.shouts.createdAt, uri: tables.shouts.uri, parent: tables.shouts.parentId, likes: count(tables.shoutLikes.id).as("likes"), }, users: { id: tables.users.id, did: tables.users.did, handle: tables.users.handle, displayName: tables.users.displayName, avatar: tables.users.avatar, }, }) .from(tables.shouts) .leftJoin(tables.users, eq(tables.shouts.authorId, tables.users.id)) .leftJoin(tables.tracks, eq(tables.shouts.trackId, tables.tracks.id)) .leftJoin( tables.shoutLikes, eq(tables.shouts.id, tables.shoutLikes.shoutId), ) .where(eq(tables.tracks.uri, `at://${did}/app.rocksky.song/${rkey}`)) .groupBy( tables.shouts.id, tables.shouts.content, tables.shouts.createdAt, tables.shouts.uri, tables.shouts.parentId, ...shoutGifGroupBy, tables.users.id, tables.users.did, tables.users.handle, tables.users.displayName, tables.users.avatar, ) .orderBy(desc(tables.shouts.createdAt)) .execute();
return c.json(shouts);});
app.get("/:did/app.rocksky.scrobble/:rkey/shouts", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.scrobble/:rkey/shouts", }); const did = c.req.param("did"); const rkey = c.req.param("rkey");
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
let user: SelectUser | undefined; if (bearer && bearer !== "null") { const payload = await verifyToken(bearer);
user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); }
const shouts = await ctx.db .select({ shouts: user ? { id: tables.shouts.id, content: tables.shouts.content, ...shoutGifColumns, createdAt: tables.shouts.createdAt, uri: tables.shouts.uri, parent: tables.shouts.parentId, likes: count(tables.shoutLikes.id).as("likes"), liked: sql<boolean>` EXISTS ( SELECT 1 FROM ${tables.shoutLikes} WHERE ${tables.shoutLikes}.shout_id = ${tables.shouts}.xata_id AND ${tables.shoutLikes}.user_id = ${user.id} )`.as("liked"), } : { id: tables.shouts.id, content: tables.shouts.content, ...shoutGifColumns, createdAt: tables.shouts.createdAt, uri: tables.shouts.uri, parent: tables.shouts.parentId, likes: count(tables.shoutLikes.id).as("likes"), }, users: { id: tables.users.id, did: tables.users.did, handle: tables.users.handle, displayName: tables.users.displayName, avatar: tables.users.avatar, }, }) .from(tables.shouts) .leftJoin(tables.users, eq(tables.shouts.authorId, tables.users.id)) .leftJoin( tables.scrobbles, eq(tables.shouts.scrobbleId, tables.scrobbles.id), ) .leftJoin( tables.shoutLikes, eq(tables.shouts.id, tables.shoutLikes.shoutId), ) .where(eq(tables.scrobbles.uri, `at://${did}/app.rocksky.scrobble/${rkey}`)) .groupBy( tables.shouts.id, tables.shouts.content, tables.shouts.createdAt, tables.shouts.uri, tables.shouts.parentId, ...shoutGifGroupBy, tables.users.id, tables.users.did, tables.users.handle, tables.users.displayName, tables.users.avatar, ) .orderBy(desc(tables.shouts.createdAt)) .execute();
return c.json(shouts);});
app.get("/:did/shouts", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/shouts" }); const did = c.req.param("did");
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
let user: SelectUser | undefined; if (bearer && bearer !== "null") { const payload = await verifyToken(bearer);
user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]); }
const shouts = await ctx.db .select({ profileShouts: { id: tables.profileShouts.id, createdAt: tables.profileShouts.createdAt, }, shouts: user ? { id: tables.shouts.id, content: tables.shouts.content, ...shoutGifColumns, createdAt: tables.shouts.createdAt, uri: tables.shouts.uri, parent: tables.shouts.parentId, likes: count(tables.shoutLikes.id).as("likes"), liked: sql<boolean>` EXISTS ( SELECT 1 FROM ${tables.shoutLikes} WHERE ${tables.shoutLikes}.shout_id = ${tables.shouts}.xata_id AND ${tables.shoutLikes}.user_id = ${user.id} )`.as("liked"), reported: sql<boolean>` EXISTS ( SELECT 1 FROM ${tables.shoutReports} WHERE ${tables.shoutReports}.shout_id = ${tables.shouts}.xata_id AND ${tables.shoutReports}.user_id = ${user.id} )`.as("reported"), } : { id: tables.shouts.id, content: tables.shouts.content, ...shoutGifColumns, createdAt: tables.shouts.createdAt, uri: tables.shouts.uri, parent: tables.shouts.parentId, likes: count(tables.shoutLikes.id).as("likes"), }, users: { id: aliasedTable(tables.users, "authors").id, did: aliasedTable(tables.users, "authors").did, handle: aliasedTable(tables.users, "authors").handle, displayName: aliasedTable(tables.users, "authors").displayName, avatar: aliasedTable(tables.users, "authors").avatar, }, }) .from(tables.profileShouts) .where(or(eq(tables.users.did, did), eq(tables.users.handle, did))) .leftJoin(tables.shouts, eq(tables.profileShouts.shoutId, tables.shouts.id)) .leftJoin( aliasedTable(tables.users, "authors"), eq(tables.shouts.authorId, aliasedTable(tables.users, "authors").id), ) .leftJoin(tables.users, eq(tables.profileShouts.userId, tables.users.id)) .leftJoin( tables.shoutLikes, eq(tables.shouts.id, tables.shoutLikes.shoutId), ) .groupBy( tables.profileShouts.id, tables.profileShouts.createdAt, tables.shouts.id, tables.shouts.uri, tables.shouts.content, tables.shouts.createdAt, tables.shouts.parentId, ...shoutGifGroupBy, tables.users.id, tables.users.did, tables.users.handle, tables.users.displayName, tables.users.avatar, aliasedTable(tables.users, "authors").id, aliasedTable(tables.users, "authors").did, aliasedTable(tables.users, "authors").handle, aliasedTable(tables.users, "authors").displayName, aliasedTable(tables.users, "authors").avatar, ) .orderBy(desc(tables.profileShouts.createdAt)) .execute();
return c.json(shouts);});
app.get("/:did/app.rocksky.shout/:rkey/likes", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.shout/:rkey/likes", }); const did = c.req.param("did"); const rkey = c.req.param("rkey"); const likes = await ctx.db .select() .from(tables.shoutLikes) .leftJoin(tables.users, eq(tables.shoutLikes.userId, tables.users.id)) .leftJoin(tables.shouts, eq(tables.shoutLikes.shoutId, tables.shouts.id)) .where(eq(tables.shouts.uri, `at://${did}/app.rocksky.shout/${rkey}`)) .execute(); return c.json(likes);});
app.get("/:did/app.rocksky.shout/:rkey/replies", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/app.rocksky.shout/:rkey/replies", }); const did = c.req.param("did"); const rkey = c.req.param("rkey"); const shouts = await ctx.db .select() .from(tables.shouts) .leftJoin(tables.users, eq(tables.shouts.authorId, tables.users.id)) .where(eq(tables.shouts.parentId, `at://${did}/app.rocksky.shout/${rkey}`)) .orderBy(asc(tables.shouts.createdAt)) .execute(); return c.json(shouts);});
app.get("/:did/stats", async (c) => { requestCounter.add(1, { method: "GET", route: "/users/:did/stats" }); const did = c.req.param("did");
const { data } = await ctx.analytics.post("library.getStats", { user_did: did, });
return c.json({ scrobbles: data.scrobbles, artists: data.artists, lovedTracks: data.loved_tracks, albums: data.albums, tracks: data.tracks, });});
app.post("/:did/app.rocksky.shout/:rkey/report", async (c) => { requestCounter.add(1, { method: "POST", route: "/users/:did/app.rocksky.shout/:rkey/report", }); const did = c.req.param("did"); const rkey = c.req.param("rkey");
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const shout = await ctx.db .select() .from(tables.shouts) .where(eq(tables.shouts.uri, `at://${did}/app.rocksky.shout/${rkey}`)) .limit(1) .execute() .then((rows) => rows[0]);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]);
if (!shout) { c.status(404); return c.text("Shout not found"); }
if (!user) { c.status(401); return c.text("Unauthorized"); }
const existingReport = await ctx.db .select() .from(tables.shoutReports) .where( and( eq(tables.shoutReports.userId, user.id), eq(tables.shoutReports.shoutId, shout.id), ), ) .limit(1) .execute() .then((rows) => rows[0]);
if (existingReport) { return c.json(existingReport); }
const report = await ctx.db .insert(tables.shoutReports) .values({ userId: user.id, shoutId: shout.id, }) .returning() .execute() .then((rows) => rows[0]);
return c.json(report);});
app.delete("/:did/app.rocksky.shout/:rkey/report", async (c) => { requestCounter.add(1, { method: "DELETE", route: "/users/:did/app.rocksky.shout/:rkey/report", }); const did = c.req.param("did"); const rkey = c.req.param("rkey");
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const shout = await ctx.db .select() .from(tables.shouts) .where(eq(tables.shouts.uri, `at://${did}/app.rocksky.shout/${rkey}`)) .limit(1) .execute() .then((rows) => rows[0]);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]);
if (!shout) { c.status(404); return c.text("Shout not found"); }
if (!user) { c.status(401); return c.text("Unauthorized"); }
const report = await ctx.db .select() .from(tables.shoutReports) .where( and( eq(tables.shoutReports.userId, user.id), eq(tables.shoutReports.shoutId, shout.id), ), ) .limit(1) .execute() .then((rows) => rows[0]);
if (!report) { c.status(404); return c.text("Report not found"); }
if (report.userId !== user.id) { c.status(403); return c.text("Forbidden"); }
await ctx.db .delete(tables.shoutReports) .where(eq(tables.shoutReports.id, report.id)) .execute();
return c.json(report);});
app.delete("/:did/app.rocksky.shout/:rkey", async (c) => { requestCounter.add(1, { method: "DELETE", route: "/users/:did/app.rocksky.shout/:rkey", }); const did = c.req.param("did"); const rkey = c.req.param("rkey");
const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim();
if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); }
const payload = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, payload.did);
const user = await ctx.db .select() .from(tables.users) .where(eq(tables.users.did, payload.did)) .limit(1) .execute() .then((rows) => rows[0]);
if (!user) { c.status(401); return c.text("Unauthorized"); }
const shout = await ctx.db .select() .from(tables.shouts) .where(eq(tables.shouts.uri, `at://${did}/app.rocksky.shout/${rkey}`)) .limit(1) .execute() .then((rows) => rows[0]);
if (!shout) { c.status(404); return c.text("Shout not found"); }
if (shout.authorId !== user.id) { c.status(403); return c.text("Forbidden"); }
const replies = await ctx.db .select({ replies: { id: tables.shouts.id, }, }) .from(tables.shouts) .where(eq(tables.shouts.parentId, shout.id)) .execute();
const replyIds = replies.map(({ replies: r }) => r.id);
// Delete related records in the correct order await ctx.db .delete(tables.shoutLikes) .where(inArray(tables.shoutLikes.shoutId, replyIds)) .execute();
await ctx.db .delete(tables.shoutReports) .where(inArray(tables.shoutReports.shoutId, replyIds)) .execute();
await ctx.db .delete(tables.profileShouts) .where(eq(tables.profileShouts.shoutId, shout.id)) .execute();
await ctx.db .delete(tables.profileShouts) .where(inArray(tables.profileShouts.shoutId, replyIds)) .execute();
await ctx.db .delete(tables.shoutLikes) .where(eq(tables.shoutLikes.shoutId, shout.id)) .execute();
await ctx.db .delete(tables.shoutReports) .where(eq(tables.shoutReports.shoutId, shout.id)) .execute();
await ctx.db .delete(tables.shouts) .where(inArray(tables.shouts.id, replyIds)) .execute();
await ctx.db .delete(tables.shouts) .where(eq(tables.shouts.id, shout.id)) .execute();
await agent.com.atproto.repo.deleteRecord({ repo: agent.assertDid, collection: "app.rocksky.shout", rkey: shout.uri.split("/").pop(), });
return c.json(shout);});
export default app;