From b82a08cc0c15727eb07fed638d06a3b0a088bffa Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sat, 31 Jan 2026 21:49:20 +0300 Subject: [PATCH] Add OpenGraph service and proxy meta injection --- apps/api/src/index.ts | 3 + apps/api/src/opengraph/app.ts | 134 ++++++++++++++++++++++++++++ apps/app-proxy/src/html-rewriter.ts | 96 ++++++++++++++++++++ apps/app-proxy/src/index.ts | 39 +++++++- 4 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/opengraph/app.ts create mode 100644 apps/app-proxy/src/html-rewriter.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 08f9b31c..453547d7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -38,6 +38,7 @@ import spotify from "./spotify/app"; import "./tracing"; import usersApp from "./users/app"; import webscrobbler from "./webscrobbler/app"; +import opengraph from "./opengraph/app"; dns.setDefaultResultOrder("ipv4first"); @@ -82,6 +83,8 @@ app.route("/googledrive", googledrive); app.route("/apikeys", apikeys); +app.route("/public/og", opengraph); + app.get("/ws", upgradeWebSocket(handleWebsocket)); app.get("/", async (c) => { diff --git a/apps/api/src/opengraph/app.ts b/apps/api/src/opengraph/app.ts new file mode 100644 index 00000000..eb7cf7dc --- /dev/null +++ b/apps/api/src/opengraph/app.ts @@ -0,0 +1,134 @@ +import { Hono } from "hono"; +import { ctx } from "context"; +import users from "schema/users"; +import albums, { SelectAlbum } from "schema/albums"; +import artists, { SelectArtist } from "schema/artists"; +import tracks, { SelectTrack } from "schema/tracks"; +import scrobbles from "schema/scrobbles"; +import { eq } from "drizzle-orm"; + +const app = new Hono(); + +app.get("/", async (c) => { + const path = c.req.query("path"); + + if (!path) { + return c.text("OG Service: please provide a path query parameter.", 400); + } + + let m = path.match(/^\/profile\/([^/]+)$/); + if (m) { + const handle = decodeURIComponent(m[1]); + const user = await ctx.db + .select() + .from(users) + .where(eq(users.handle, handle)) + .limit(1) + .execute() + .then(([row]) => row); + if (!user) { + return c.text("OG Service: user not found.", 404); + } + return c.json({ + title: `@${user.handle} on Rocksky`, + description: + "Rocksky user profile — recent scrobbles, top artists, albums, and tracks.", + image: user.avatar.endsWith("/@jpeg") ? undefined : user.avatar, + url: `https://rocksky.app/profile/${user.handle}`, + type: "website", + twitterCard: "summary_large_image", + }); + } + + m = path.match(/^\/(did:plc:[^/]+)\/(scrobble|album|artist|song)\/([^/]+)$/); + if (m) { + const did = decodeURIComponent(m[1]); + const kind = m[2] as "scrobble" | "album" | "artist" | "song"; + const rkey = decodeURIComponent(m[3]); + const uri = `at://${did}/app.rocksky.${kind}/${rkey}`; + + const tableMap = { + scrobble: scrobbles, + album: albums, + artist: artists, + song: tracks, + }; + + const table = tableMap[kind]; + if (kind === "scrobble") { + const record = await ctx.db + .select({ + scrobbles: scrobbles, + users: users, + tracks: tracks, + artists: artists, + albums: albums, + }) + .from(table) + .where(eq(table.uri, uri)) + .leftJoin(users, eq(scrobbles.userId, users.id)) + .leftJoin(tracks, eq(scrobbles.trackId, tracks.id)) + .leftJoin(artists, eq(scrobbles.artistId, artists.id)) + .leftJoin(albums, eq(scrobbles.albumId, albums.id)) + .limit(1) + .execute() + .then(([row]) => row); + + return c.json({ + title: `Scrobble: ${record.tracks.title} by ${record.artists.name}`, + description: `A listening activity (scrobble) - ${record.tracks.title} - ${record.artists.name} by @${record.users.handle} on Rocksky.`, + image: record.albums.albumArt, + url: `https://rocksky.app/${did}/scrobble/${rkey}`, + type: "website", + twitterCard: "summary_large_image", + }); + } + + const record = await ctx.db + .select() + .from(table) + .where(eq(table.uri, uri)) + .limit(1) + .execute() + .then(([row]) => row); + if (!record) { + return c.text("OG Service: record not found.", 404); + } + + let title = undefined; + let description = undefined; + let image = undefined; + const url = `https://rocksky.app/${did}/${kind}/${rkey}`; + + if (kind === "album") { + title = `Album: ${(record as SelectAlbum).title} by ${(record as SelectAlbum).artist}`; + description = `See listening stats and favorites for ${(record as SelectAlbum).title} by ${(record as SelectAlbum).artist} on Rocksky.`; + image = (record as SelectAlbum).albumArt; + } + + if (kind === "artist") { + title = `Artist: ${(record as SelectArtist).name}`; + description = `See listening stats and favorites for ${(record as SelectArtist).name} on Rocksky.`; + image = (record as SelectArtist).picture; + } + + if (kind === "song") { + title = `Track: ${(record as SelectTrack).title} by ${(record as SelectTrack).artist}`; + description = `See listening stats and favorites for ${(record as SelectTrack).title} by ${(record as SelectTrack).artist} on Rocksky.`; + image = (record as SelectTrack).albumArt; + } + + return c.json({ + title, + description, + image, + url, + type: "website", + twitterCard: "summary_large_image", + }); + } + + return c.text("OG Service: unsupported path format.", 400); +}); + +export default app; diff --git a/apps/app-proxy/src/html-rewriter.ts b/apps/app-proxy/src/html-rewriter.ts new file mode 100644 index 00000000..3ec96a2b --- /dev/null +++ b/apps/app-proxy/src/html-rewriter.ts @@ -0,0 +1,96 @@ +export type OgTarget = + | { kind: 'profile'; handle: string } + | { kind: 'scrobble'; did: string; rkey: string } + | { kind: 'album'; did: string; rkey: string } + | { kind: 'artist'; did: string; rkey: string } + | { kind: 'song'; did: string; rkey: string } + | null; + +export function matchOgTarget(pathname: string): OgTarget { + let m = pathname.match(/^\/profile\/([^/]+)$/); + if (m) return { kind: 'profile', handle: decodeURIComponent(m[1]) }; + + m = pathname.match(/^\/(did:plc:[^/]+)\/(scrobble|album|artist|song)\/([^/]+)$/); + if (m) { + const did = decodeURIComponent(m[1]); + const kind = m[2] as 'scrobble' | 'album' | 'artist' | 'song'; + const rkey = decodeURIComponent(m[3]); + if (kind === 'song') return { kind: 'song', did, rkey }; + if (kind === 'album') return { kind: 'album', did, rkey }; + if (kind === 'artist') return { kind: 'artist', did, rkey }; + return { kind: 'scrobble', did, rkey }; + } + + return null; +} + +export type OgData = { + title: string; + description: string; + image: string; + url: string; + type?: string; // og:type + twitterCard?: 'summary' | 'summary_large_image'; +}; + +export async function fetchOgData(url: URL, request: Request): Promise { + const api = new URL('https://api.rocksky.app/public/og'); + api.searchParams.set('path', url.pathname); + + const res = await fetch(api.toString(), { + headers: { + accept: 'application/json', + 'accept-language': request.headers.get('accept-language') ?? 'en', + }, + }); + + if (!res.ok) return null; + + const og = (await res.json()) as OgData; + og.url ??= url.toString(); + og.type ??= 'website'; + og.twitterCard ??= 'summary_large_image'; + return og; +} + +function escapeAttr(s: string) { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + +export class StripMeta { + element(el: Element) { + el.remove(); + } +} + +export function isHtmlResponse(res: Response) { + const ct = res.headers.get('content-type') || ''; + return ct.toLowerCase().includes('text/html'); +} + +export class HeadMeta { + private og: OgData; + constructor(og: OgData) { + this.og = og; + } + + element(head: Element) { + const tags = [ + `${escapeAttr(this.og.title)}`, + ``, + + ``, + ``, + ``, + ``, + ``, + + ``, + ``, + ``, + ``, + ].join('\n'); + + head.append(tags, { html: true }); + } +} diff --git a/apps/app-proxy/src/index.ts b/apps/app-proxy/src/index.ts index 59147a6f..e96b6293 100644 --- a/apps/app-proxy/src/index.ts +++ b/apps/app-proxy/src/index.ts @@ -11,6 +11,8 @@ * Learn more at https://developers.cloudflare.com/workers/ */ +import { fetchOgData, HeadMeta, isHtmlResponse, StripMeta } from './html-rewriter'; + const metadata = { redirect_uris: ['https://rocksky.app/oauth/callback'], response_types: ['code'], @@ -116,12 +118,45 @@ export default { const mobileUrl = new URL(request.url); mobileUrl.host = 'm.rocksky.app'; mobileUrl.hostname = 'm.rocksky.app'; - return fetch(mobileUrl, request); + const htmlRes = await fetch(mobileUrl, request); + if (!htmlRes.ok || !isHtmlResponse(htmlRes)) { + return htmlRes; + } + + const og = await fetchOgData(url, request); + if (!og) return htmlRes; + const headers = new Headers(htmlRes.headers); + headers.set('cache-control', 'public, max-age=300'); + + const rewritten = new HTMLRewriter() + .on('meta[property^="og:"]', new StripMeta()) + .on('meta[name^="twitter:"]', new StripMeta()) + .on('head', new HeadMeta(og)) + .transform(htmlRes); + + return new Response(rewritten.body, { status: htmlRes.status, headers }); } const proxyUrl = new URL(request.url); proxyUrl.host = 'rocksky.pages.dev'; proxyUrl.hostname = 'rocksky.pages.dev'; - return fetch(proxyUrl, request) as any; + const htmlRes = await fetch(proxyUrl, request); + if (!htmlRes.ok || !isHtmlResponse(htmlRes)) { + return htmlRes; + } + + const og = await fetchOgData(url, request); + if (!og) return htmlRes; + + const headers = new Headers(htmlRes.headers); + headers.set('cache-control', 'public, max-age=300'); + + const rewritten = new HTMLRewriter() + .on('meta[property^="og:"]', new StripMeta()) + .on('meta[name^="twitter:"]', new StripMeta()) + .on('head', new HeadMeta(og)) + .transform(htmlRes); + + return new Response(rewritten.body, { status: htmlRes.status, headers }); }, } satisfies ExportedHandler; -- 2.51.2