import "./otel"; import dns from "node:dns"; import { serve } from "@hono/node-server"; import { trace } from "@opentelemetry/api"; import chalk from "chalk"; import { consola } from "consola"; import { ctx } from "context"; import { and, desc, eq, isNotNull, or } from "drizzle-orm"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { createAgent, pdsSessionExpired } from "lib/agent"; import { creditsArtist } from "lib/credits"; import { assertNotBotFlagged, assertNotScrobbleBlocked, ScrobbleBlockedError, ScrobbleBotFlaggedError, scrobbleBlockedMessage, scrobbleBotFlaggedMessage, } from "lib/scrobbleGuard"; import { verifyToken } from "lib/verifyToken"; import { getLovedTracks, likeTrack, unLikeTrack, } from "lovedtracks/lovedtracks.service"; import { enqueueSubmission, SubmissionUnavailable } from "lib/durableSubmission"; import { env } from "lib/env"; import submissionsApp from "./submissions/app"; import { rateLimiter } from "ratelimiter"; import subscribe from "subscribers"; import { saveTrack } from "tracks/tracks.service"; import { trackSchema } from "types/track"; import accessTokensApp from "./access-tokens/app"; import apikeys from "./apikeys/app"; import bsky from "./bsky/app"; import { requestCounter, requestDuration } from "./metrics"; import notificationsSse from "./notifications/sse"; import storageApp from "./storage/app"; import uploadsApp from "./uploads/app"; import "./profiling"; import opengraph from "./opengraph/app"; import albumTracks from "./schema/album-tracks"; import albums from "./schema/albums"; import artistTracks from "./schema/artist-tracks"; import artists from "./schema/artists"; import scrobbles from "./schema/scrobbles"; import tracks from "./schema/tracks"; import users from "./schema/users"; import spotify from "./spotify/app"; import usersApp from "./users/app"; import webscrobbler from "./webscrobbler/app"; dns.setDefaultResultOrder("ipv4first"); subscribe(ctx); const app = new Hono(); // Authenticated internal worker traffic has its own bounded concurrency. app.route("/internal/submissions", submissionsApp); app.use( "*", rateLimiter({ limit: 1000, window: 30, // 👈 30 seconds }), ); app.use("*", async (c, next) => { const span = trace.getActiveSpan(); span?.updateName(`${c.req.method} ${c.req.path}`); span?.setAttribute("http.route", c.req.path); await next(); }); app.use("*", async (c, next) => { const start = Date.now(); await next(); const duration = (Date.now() - start) / 1000; requestDuration.record(duration, { route: c.req.path, method: c.req.method, }); }); app.use(cors()); app.route("/", bsky); app.route("/spotify", spotify); app.route("/storage", storageApp); app.route("/apikeys", apikeys); app.route("/access-tokens", accessTokensApp); app.route("/notifications", notificationsSse); app.route("/public/og", opengraph); // Image proxy — fetches remote images server-side to avoid browser CORS restrictions app.get("/proxy-image", async (c) => { const url = c.req.query("url"); if (!url) return c.text("Missing url", 400); try { const upstream = await fetch(url); if (!upstream.ok) return c.text("Upstream error", 502); const contentType = upstream.headers.get("content-type") ?? "image/jpeg"; return new Response(upstream.body, { headers: { "content-type": contentType }, }); } catch { return c.text("Failed to fetch image", 502); } }); // The player remote-control WebSocket (/ws) is now served by the Elixir // `remote-ws` service; Caddy routes /ws there. See remote-ws/README.md. app.get("/", async (c) => { return c.text("\n ____ __ __ ___ _ ___\n / __ \\____ _____/ /_______/ /____ __ / | ____ ____| | / (_)__ _ __\n / /_/ / __ \\/ ___/ //_/ ___/ //_/ / / / / /| | / __ \\/ __ \\ | / / / _ \\ | /| / /\n / _, _/ /_/ / /__/ ,< (__ ) ,< / /_/ / / ___ |/ /_/ / /_/ / |/ / / __/ |/ |/ /\n/_/ |_|\\____/\\___/_/|_/____/_/|_|\\__, / /_/ |_/ .___/ .___/|___/_/\\___/|__/|__/\n /____/ /_/ /_/\n\nRocksky AppView\n\nDocs: https://docs.rocksky.app\nSource: https://tangled.org/rocksky.app/rocksky\n"); }); app.post("/now-playing", async (c) => { requestCounter.add(1, { method: "POST", route: "/now-playing" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); } const { did } = await verifyToken(bearer); const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); } const body = await c.req.json(); consola.debug( `[now-playing] received payload for ${chalk.cyan(did)}:`, JSON.stringify(body), ); const parsed = trackSchema.safeParse(body); if (parsed.error) { consola.warn( `[now-playing] validation failed for ${chalk.cyan(did)}:`, parsed.error.flatten(), ); c.status(400); return c.text("Invalid track data: " + parsed.error.message); } const track = parsed.data; consola.debug( `[now-playing] validation passed for ${chalk.cyan(did)} — title="${track.title}" artist="${track.artist}" duration=${track.duration} timestamp=${track.timestamp}`, ); const agent = await createAgent(ctx.oauthClient, did); if (!agent) { consola.warn( `[now-playing] no agent for ${chalk.cyan(did)}, returning 401`, ); c.status(401); return c.json(pdsSessionExpired); } consola.debug( `[now-playing] agent created for ${chalk.cyan(did)}, accepting durable submission`, ); try { await assertNotBotFlagged(ctx, user.did); await assertNotScrobbleBlocked(ctx, user.did); const key = c.req.header("idempotency-key"); if (key && key.length > 512) return c.json({ error: "Invalid Idempotency-Key" }, 400); const submissionId = await enqueueSubmission( { url: env.SUBMISSIONS_URL, secret: env.SUBMISSIONS_SECRET }, user.did, track, key, ); c.header("X-Submission-Id", submissionId); } catch (err) { if (err instanceof SubmissionUnavailable) return c.json({ error: err.message }, 503); if (err instanceof ScrobbleBotFlaggedError) { consola.warn( `[now-playing] rejected bot-flagged account ${chalk.cyan(did)}`, ); c.status(429); return c.json({ status: "blocked", error: "account_flagged_as_bot", message: scrobbleBotFlaggedMessage(), }); } if (err instanceof ScrobbleBlockedError) { consola.warn( `[now-playing] blocked suspected bot ${chalk.cyan(did)} — retry after ${err.retryAfter}s`, ); c.status(429); c.header("Retry-After", err.retryAfter.toString()); return c.json({ status: "blocked", error: "scrobble_rate_exceeded", message: scrobbleBlockedMessage(err.retryAfter), retryAfter: err.retryAfter, }); } throw err; } return c.json({ status: "ok" }); }); app.get("/now-playing", async (c) => { requestCounter.add(1, { method: "GET", route: "/now-playing" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); const payload = bearer && bearer !== "null" ? await verifyToken(bearer) : {}; const did = c.req.query("did") || payload.did; if (!did) { c.status(401); return c.text("Unauthorized"); } const user = await ctx.db .select() .from(users) .where(or(eq(users.did, did), eq(users.handle, did))) .limit(1) .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); } const [nowPlaying, status] = await Promise.all([ ctx.redis.get(`nowplaying:${user.did}`), ctx.redis.get(`nowplaying:${user.did}:status`), ]); if (!nowPlaying) { return c.json({}); } // Every `track` frame carries the device's own transport state, and remote-ws // keeps it in step with `status` pushes, so the blob is the truth. Overwriting // it from the status key unconditionally made every player that reports // is_playing but never calls setStatus look permanently paused. const track = JSON.parse(nowPlaying); return c.json({ ...track, is_playing: typeof track.is_playing === "boolean" ? track.is_playing : status === "1", }); }); app.get("/now-playings", async (c) => { requestCounter.add(1, { method: "GET", route: "/now-playings" }); const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0; const { data } = await ctx.analytics.post("library.getDistinctScrobbles", { pagination: { skip: offset, take: size, }, }); return c.json(data); }); app.post("/likes", async (c) => { requestCounter.add(1, { method: "POST", route: "/likes" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); } const { did } = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, did); if (!agent) { consola.warn(`[likes] no agent for ${chalk.cyan(did)}, returning 401`); c.status(401); return c.json(pdsSessionExpired); } const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); } const body = await c.req.json(); const parsed = trackSchema.safeParse(body); if (parsed.error) { c.status(400); return c.text("Invalid track data: " + parsed.error.message); } const track = parsed.data; await likeTrack(ctx, track, user, agent); return c.json({ status: "ok" }); }); app.delete("/likes/:sha256", async (c) => { requestCounter.add(1, { method: "DELETE", route: "/likes/:sha256" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); } const { did } = await verifyToken(bearer); const agent = await createAgent(ctx.oauthClient, did); if (!agent) { consola.warn(`[likes] no agent for ${chalk.cyan(did)}, returning 401`); c.status(401); return c.json(pdsSessionExpired); } const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); } const sha256 = c.req.param("sha256"); await unLikeTrack(ctx, sha256, user, agent); return c.json({ status: "ok" }); }); app.get("/likes", async (c) => { requestCounter.add(1, { method: "GET", route: "/likes" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); } const { did } = await verifyToken(bearer); const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); } const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0; const lovedTracks = await getLovedTracks(ctx, user, size, offset); return c.json(lovedTracks); }); app.get("/public/scrobbles", async (c) => { requestCounter.add(1, { method: "GET", route: "/public/scrobbles" }); const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0; const scrobbleRecords = await ctx.db .select({ scrobble: scrobbles, track: tracks, user: users, }) .from(scrobbles) .innerJoin(tracks, eq(scrobbles.trackId, tracks.id)) .innerJoin(users, eq(scrobbles.userId, users.id)) .orderBy(desc(scrobbles.timestamp)) .limit(size) .offset(offset); return c.json( scrobbleRecords.map((item) => ({ cover: item.track.albumArt, artist: item.track.artist, title: item.track.title, date: item.scrobble.timestamp, user: item.user.handle, uri: item.scrobble.uri, albumUri: item.track.albumUri, artistUri: item.track.artistUri, tags: [], listeners: 1, sha256: item.track.sha256, id: item.scrobble.id, })), ); }); app.get("/public/scrobbleschart", async (c) => { requestCounter.add(1, { method: "GET", route: "/public/scrobbleschart" }); const did = c.req.query("did"); const artisturi = c.req.query("artisturi"); const albumuri = c.req.query("albumuri"); const songuri = c.req.query("songuri"); if (did) { const chart = await ctx.analytics.post("library.getScrobblesPerDay", { user_did: did, }); return c.json(chart.data); } if (artisturi) { const chart = await ctx.analytics.post("library.getArtistScrobbles", { artist_id: artisturi, }); return c.json(chart.data); } if (albumuri) { const chart = await ctx.analytics.post("library.getAlbumScrobbles", { album_id: albumuri, }); return c.json(chart.data); } if (songuri) { let uri = songuri; if (songuri.includes("app.rocksky.scrobble")) { const scrobble = await ctx.db .select({ scrobble: scrobbles, track: tracks, }) .from(scrobbles) .innerJoin(tracks, eq(scrobbles.trackId, tracks.id)) .where(eq(scrobbles.uri, songuri)) .limit(1) .then((rows) => rows[0]); uri = scrobble.track.uri; } const chart = await ctx.analytics.post("library.getTrackScrobbles", { track_id: uri, }); return c.json(chart.data); } const chart = await ctx.analytics.post("library.getScrobblesPerDay", {}); return c.json(chart.data); }); app.get("/scrobbles", async (c) => { requestCounter.add(1, { method: "GET", route: "/scrobbles" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); } const { did } = await verifyToken(bearer); const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); } const size = +c.req.query("size") || 10; const offset = +c.req.query("offset") || 0; const userScrobbles = await ctx.db .select({ scrobble: scrobbles, track: tracks, }) .from(scrobbles) .innerJoin(tracks, eq(scrobbles.trackId, tracks.id)) .where(and(eq(scrobbles.userId, user.id), isNotNull(scrobbles.uri))) .orderBy(desc(scrobbles.createdAt)) .limit(size) .offset(offset); return c.json(userScrobbles); }); app.post("/tracks", async (c) => { requestCounter.add(1, { method: "POST", route: "/tracks" }); const bearer = (c.req.header("authorization") || "").split(" ")[1]?.trim(); if (!bearer || bearer === "null") { c.status(401); return c.text("Unauthorized"); } const { did } = await verifyToken(bearer); const user = await ctx.db .select() .from(users) .where(eq(users.did, did)) .limit(1) .then((rows) => rows[0]); if (!user) { c.status(401); return c.text("Unauthorized"); } const body = await c.req.json(); const parsed = trackSchema.safeParse(body); if (parsed.error) { c.status(400); return c.text("Invalid track data: " + parsed.error.message); } const track = parsed.data; const agent = await createAgent(ctx.oauthClient, did); if (!agent) { consola.warn(`[tracks] no agent for ${chalk.cyan(did)}, returning 401`); c.status(401); return c.json(pdsSessionExpired); } try { await saveTrack(ctx, track, agent); } catch (e) { if (!e.message.includes("duplicate key value violates unique constraint")) { consola.error("[tracks]", e.message); } } return c.json({ status: "ok" }); }); app.get("/tracks", async (c) => { requestCounter.add(1, { method: "GET", route: "/tracks" }); const size = +c.req.query("size") || 100; const offset = +c.req.query("offset") || 0; const tracksData = await ctx.analytics.post("library.getTracks", { pagination: { skip: offset, take: size, }, }); return c.json(tracksData.data); }); app.get("/albums", async (c) => { requestCounter.add(1, { method: "GET", route: "/albums" }); const size = +c.req.query("size") || 100; const offset = +c.req.query("offset") || 0; const albumsData = await ctx.analytics.post("library.getAlbums", { pagination: { skip: offset, take: size, }, }); return c.json(albumsData.data); }); app.get("/artists", async (c) => { requestCounter.add(1, { method: "GET", route: "/artists" }); const size = +c.req.query("size") || 100; const offset = +c.req.query("offset") || 0; const artistsData = await ctx.analytics.post("library.getArtists", { pagination: { skip: offset, take: size, }, }); return c.json(artistsData.data); }); app.get("/tracks/:sha256", async (c) => { requestCounter.add(1, { method: "GET", route: "/tracks/:sha256" }); const sha256 = c.req.param("sha256"); const track = await ctx.db .select() .from(tracks) .where(eq(tracks.sha256, sha256)) .limit(1) .then((rows) => rows[0]); return c.json(track); }); app.get("/albums/:sha256", async (c) => { requestCounter.add(1, { method: "GET", route: "/albums/:sha256" }); const sha256 = c.req.param("sha256"); const album = await ctx.db .select() .from(albums) .where(eq(albums.sha256, sha256)) .limit(1) .then((rows) => rows[0]); return c.json(album); }); app.get("/artists/:sha256", async (c) => { requestCounter.add(1, { method: "GET", route: "/artists/:sha256" }); const sha256 = c.req.param("sha256"); const artist = await ctx.db .select() .from(artists) .where(eq(artists.sha256, sha256)) .limit(1) .then((rows) => rows[0]); return c.json(artist); }); app.get("/artists/:sha256/tracks", async (c) => { requestCounter.add(1, { method: "GET", route: "/artists/:sha256/tracks" }); const sha256 = c.req.param("sha256"); const artistTracksData = await ctx.db .select({ track: tracks, artistName: artists.name, }) .from(artistTracks) .innerJoin(tracks, eq(artistTracks.trackId, tracks.id)) .innerJoin(artists, eq(artistTracks.artistId, artists.id)) .where(eq(artists.sha256, sha256)); return c.json( artistTracksData // artist_tracks has rows linking tracks to artists that don't credit // them; without the guard one of them hands back a stranger's track. .filter((item) => creditsArtist(item.artistName, item.track)) .map((item) => item.track), ); }); app.get("/albums/:sha256/tracks", async (c) => { requestCounter.add(1, { method: "GET", route: "/albums/:sha256/tracks" }); const sha256 = c.req.param("sha256"); const albumTracksData = await ctx.db .select({ track: tracks, }) .from(albumTracks) .innerJoin(tracks, eq(albumTracks.trackId, tracks.id)) .innerJoin(albums, eq(albumTracks.albumId, albums.id)) .where(eq(albums.sha256, sha256)); return c.json(albumTracksData.map((item) => item.track)); }); app.route("/uploads", uploadsApp); app.route("/users", usersApp); app.route("/webscrobbler", webscrobbler); serve({ fetch: app.fetch, port: 8000, });