A decentralized music tracking and discovery platform built on AT Protocol 🎵

Add OpenGraph service and proxy meta injection

+270 -2
+3
apps/api/src/index.ts
··· 38 38 import "./tracing"; 39 39 import usersApp from "./users/app"; 40 40 import webscrobbler from "./webscrobbler/app"; 41 + import opengraph from "./opengraph/app"; 41 42 42 43 dns.setDefaultResultOrder("ipv4first"); 43 44 ··· 81 82 app.route("/googledrive", googledrive); 82 83 83 84 app.route("/apikeys", apikeys); 85 + 86 + app.route("/public/og", opengraph); 84 87 85 88 app.get("/ws", upgradeWebSocket(handleWebsocket)); 86 89
+134
apps/api/src/opengraph/app.ts
··· 1 + import { Hono } from "hono"; 2 + import { ctx } from "context"; 3 + import users from "schema/users"; 4 + import albums, { SelectAlbum } from "schema/albums"; 5 + import artists, { SelectArtist } from "schema/artists"; 6 + import tracks, { SelectTrack } from "schema/tracks"; 7 + import scrobbles from "schema/scrobbles"; 8 + import { eq } from "drizzle-orm"; 9 + 10 + const app = new Hono(); 11 + 12 + app.get("/", async (c) => { 13 + const path = c.req.query("path"); 14 + 15 + if (!path) { 16 + return c.text("OG Service: please provide a path query parameter.", 400); 17 + } 18 + 19 + let m = path.match(/^\/profile\/([^/]+)$/); 20 + if (m) { 21 + const handle = decodeURIComponent(m[1]); 22 + const user = await ctx.db 23 + .select() 24 + .from(users) 25 + .where(eq(users.handle, handle)) 26 + .limit(1) 27 + .execute() 28 + .then(([row]) => row); 29 + if (!user) { 30 + return c.text("OG Service: user not found.", 404); 31 + } 32 + return c.json({ 33 + title: `@${user.handle} on Rocksky`, 34 + description: 35 + "Rocksky user profile — recent scrobbles, top artists, albums, and tracks.", 36 + image: user.avatar.endsWith("/@jpeg") ? undefined : user.avatar, 37 + url: `https://rocksky.app/profile/${user.handle}`, 38 + type: "website", 39 + twitterCard: "summary_large_image", 40 + }); 41 + } 42 + 43 + m = path.match(/^\/(did:plc:[^/]+)\/(scrobble|album|artist|song)\/([^/]+)$/); 44 + if (m) { 45 + const did = decodeURIComponent(m[1]); 46 + const kind = m[2] as "scrobble" | "album" | "artist" | "song"; 47 + const rkey = decodeURIComponent(m[3]); 48 + const uri = `at://${did}/app.rocksky.${kind}/${rkey}`; 49 + 50 + const tableMap = { 51 + scrobble: scrobbles, 52 + album: albums, 53 + artist: artists, 54 + song: tracks, 55 + }; 56 + 57 + const table = tableMap[kind]; 58 + if (kind === "scrobble") { 59 + const record = await ctx.db 60 + .select({ 61 + scrobbles: scrobbles, 62 + users: users, 63 + tracks: tracks, 64 + artists: artists, 65 + albums: albums, 66 + }) 67 + .from(table) 68 + .where(eq(table.uri, uri)) 69 + .leftJoin(users, eq(scrobbles.userId, users.id)) 70 + .leftJoin(tracks, eq(scrobbles.trackId, tracks.id)) 71 + .leftJoin(artists, eq(scrobbles.artistId, artists.id)) 72 + .leftJoin(albums, eq(scrobbles.albumId, albums.id)) 73 + .limit(1) 74 + .execute() 75 + .then(([row]) => row); 76 + 77 + return c.json({ 78 + title: `Scrobble: ${record.tracks.title} by ${record.artists.name}`, 79 + description: `A listening activity (scrobble) - ${record.tracks.title} - ${record.artists.name} by @${record.users.handle} on Rocksky.`, 80 + image: record.albums.albumArt, 81 + url: `https://rocksky.app/${did}/scrobble/${rkey}`, 82 + type: "website", 83 + twitterCard: "summary_large_image", 84 + }); 85 + } 86 + 87 + const record = await ctx.db 88 + .select() 89 + .from(table) 90 + .where(eq(table.uri, uri)) 91 + .limit(1) 92 + .execute() 93 + .then(([row]) => row); 94 + if (!record) { 95 + return c.text("OG Service: record not found.", 404); 96 + } 97 + 98 + let title = undefined; 99 + let description = undefined; 100 + let image = undefined; 101 + const url = `https://rocksky.app/${did}/${kind}/${rkey}`; 102 + 103 + if (kind === "album") { 104 + title = `Album: ${(record as SelectAlbum).title} by ${(record as SelectAlbum).artist}`; 105 + description = `See listening stats and favorites for ${(record as SelectAlbum).title} by ${(record as SelectAlbum).artist} on Rocksky.`; 106 + image = (record as SelectAlbum).albumArt; 107 + } 108 + 109 + if (kind === "artist") { 110 + title = `Artist: ${(record as SelectArtist).name}`; 111 + description = `See listening stats and favorites for ${(record as SelectArtist).name} on Rocksky.`; 112 + image = (record as SelectArtist).picture; 113 + } 114 + 115 + if (kind === "song") { 116 + title = `Track: ${(record as SelectTrack).title} by ${(record as SelectTrack).artist}`; 117 + description = `See listening stats and favorites for ${(record as SelectTrack).title} by ${(record as SelectTrack).artist} on Rocksky.`; 118 + image = (record as SelectTrack).albumArt; 119 + } 120 + 121 + return c.json({ 122 + title, 123 + description, 124 + image, 125 + url, 126 + type: "website", 127 + twitterCard: "summary_large_image", 128 + }); 129 + } 130 + 131 + return c.text("OG Service: unsupported path format.", 400); 132 + }); 133 + 134 + export default app;
+96
apps/app-proxy/src/html-rewriter.ts
··· 1 + export type OgTarget = 2 + | { kind: 'profile'; handle: string } 3 + | { kind: 'scrobble'; did: string; rkey: string } 4 + | { kind: 'album'; did: string; rkey: string } 5 + | { kind: 'artist'; did: string; rkey: string } 6 + | { kind: 'song'; did: string; rkey: string } 7 + | null; 8 + 9 + export function matchOgTarget(pathname: string): OgTarget { 10 + let m = pathname.match(/^\/profile\/([^/]+)$/); 11 + if (m) return { kind: 'profile', handle: decodeURIComponent(m[1]) }; 12 + 13 + m = pathname.match(/^\/(did:plc:[^/]+)\/(scrobble|album|artist|song)\/([^/]+)$/); 14 + if (m) { 15 + const did = decodeURIComponent(m[1]); 16 + const kind = m[2] as 'scrobble' | 'album' | 'artist' | 'song'; 17 + const rkey = decodeURIComponent(m[3]); 18 + if (kind === 'song') return { kind: 'song', did, rkey }; 19 + if (kind === 'album') return { kind: 'album', did, rkey }; 20 + if (kind === 'artist') return { kind: 'artist', did, rkey }; 21 + return { kind: 'scrobble', did, rkey }; 22 + } 23 + 24 + return null; 25 + } 26 + 27 + export type OgData = { 28 + title: string; 29 + description: string; 30 + image: string; 31 + url: string; 32 + type?: string; // og:type 33 + twitterCard?: 'summary' | 'summary_large_image'; 34 + }; 35 + 36 + export async function fetchOgData(url: URL, request: Request): Promise<OgData | null> { 37 + const api = new URL('https://api.rocksky.app/public/og'); 38 + api.searchParams.set('path', url.pathname); 39 + 40 + const res = await fetch(api.toString(), { 41 + headers: { 42 + accept: 'application/json', 43 + 'accept-language': request.headers.get('accept-language') ?? 'en', 44 + }, 45 + }); 46 + 47 + if (!res.ok) return null; 48 + 49 + const og = (await res.json<OgData>()) as OgData; 50 + og.url ??= url.toString(); 51 + og.type ??= 'website'; 52 + og.twitterCard ??= 'summary_large_image'; 53 + return og; 54 + } 55 + 56 + function escapeAttr(s: string) { 57 + return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 58 + } 59 + 60 + export class StripMeta { 61 + element(el: Element) { 62 + el.remove(); 63 + } 64 + } 65 + 66 + export function isHtmlResponse(res: Response) { 67 + const ct = res.headers.get('content-type') || ''; 68 + return ct.toLowerCase().includes('text/html'); 69 + } 70 + 71 + export class HeadMeta { 72 + private og: OgData; 73 + constructor(og: OgData) { 74 + this.og = og; 75 + } 76 + 77 + element(head: Element) { 78 + const tags = [ 79 + `<title>${escapeAttr(this.og.title)}</title>`, 80 + `<meta name="description" content="${escapeAttr(this.og.description)}">`, 81 + 82 + `<meta property="og:title" content="${escapeAttr(this.og.title)}">`, 83 + `<meta property="og:description" content="${escapeAttr(this.og.description)}">`, 84 + `<meta property="og:image" content="${escapeAttr(this.og.image)}">`, 85 + `<meta property="og:url" content="${escapeAttr(this.og.url)}">`, 86 + `<meta property="og:type" content="${escapeAttr(this.og.type ?? 'website')}">`, 87 + 88 + `<meta name="twitter:card" content="${escapeAttr(this.og.twitterCard ?? 'summary_large_image')}">`, 89 + `<meta name="twitter:title" content="${escapeAttr(this.og.title)}">`, 90 + `<meta name="twitter:description" content="${escapeAttr(this.og.description)}">`, 91 + `<meta name="twitter:image" content="${escapeAttr(this.og.image)}">`, 92 + ].join('\n'); 93 + 94 + head.append(tags, { html: true }); 95 + } 96 + }
+37 -2
apps/app-proxy/src/index.ts
··· 11 11 * Learn more at https://developers.cloudflare.com/workers/ 12 12 */ 13 13 14 + import { fetchOgData, HeadMeta, isHtmlResponse, StripMeta } from './html-rewriter'; 15 + 14 16 const metadata = { 15 17 redirect_uris: ['https://rocksky.app/oauth/callback'], 16 18 response_types: ['code'], ··· 116 118 const mobileUrl = new URL(request.url); 117 119 mobileUrl.host = 'm.rocksky.app'; 118 120 mobileUrl.hostname = 'm.rocksky.app'; 119 - return fetch(mobileUrl, request); 121 + const htmlRes = await fetch(mobileUrl, request); 122 + if (!htmlRes.ok || !isHtmlResponse(htmlRes)) { 123 + return htmlRes; 124 + } 125 + 126 + const og = await fetchOgData(url, request); 127 + if (!og) return htmlRes; 128 + const headers = new Headers(htmlRes.headers); 129 + headers.set('cache-control', 'public, max-age=300'); 130 + 131 + const rewritten = new HTMLRewriter() 132 + .on('meta[property^="og:"]', new StripMeta()) 133 + .on('meta[name^="twitter:"]', new StripMeta()) 134 + .on('head', new HeadMeta(og)) 135 + .transform(htmlRes); 136 + 137 + return new Response(rewritten.body, { status: htmlRes.status, headers }); 120 138 } 121 139 122 140 const proxyUrl = new URL(request.url); 123 141 proxyUrl.host = 'rocksky.pages.dev'; 124 142 proxyUrl.hostname = 'rocksky.pages.dev'; 125 - return fetch(proxyUrl, request) as any; 143 + const htmlRes = await fetch(proxyUrl, request); 144 + if (!htmlRes.ok || !isHtmlResponse(htmlRes)) { 145 + return htmlRes; 146 + } 147 + 148 + const og = await fetchOgData(url, request); 149 + if (!og) return htmlRes; 150 + 151 + const headers = new Headers(htmlRes.headers); 152 + headers.set('cache-control', 'public, max-age=300'); 153 + 154 + const rewritten = new HTMLRewriter() 155 + .on('meta[property^="og:"]', new StripMeta()) 156 + .on('meta[name^="twitter:"]', new StripMeta()) 157 + .on('head', new HeadMeta(og)) 158 + .transform(htmlRes); 159 + 160 + return new Response(rewritten.body, { status: htmlRes.status, headers }); 126 161 }, 127 162 } satisfies ExportedHandler<Env>;