tangled
alpha
login
or
join now
t1c.dev
/
rocksky
forked from
rocksky.app/rocksky
2
fork
atom
A decentralized music tracking and discovery platform built on AT Protocol 🎵
2
fork
atom
overview
issues
pulls
pipelines
Add OpenGraph service and proxy meta injection
tsiry-sandratraina.com
1 month ago
b82a08cc
8b9bb45f
+270
-2
4 changed files
expand all
collapse all
unified
split
apps
api
src
index.ts
opengraph
app.ts
app-proxy
src
html-rewriter.ts
index.ts
+3
apps/api/src/index.ts
···
38
import "./tracing";
39
import usersApp from "./users/app";
40
import webscrobbler from "./webscrobbler/app";
0
41
42
dns.setDefaultResultOrder("ipv4first");
43
···
81
app.route("/googledrive", googledrive);
82
83
app.route("/apikeys", apikeys);
0
0
84
85
app.get("/ws", upgradeWebSocket(handleWebsocket));
86
···
38
import "./tracing";
39
import usersApp from "./users/app";
40
import webscrobbler from "./webscrobbler/app";
41
+
import opengraph from "./opengraph/app";
42
43
dns.setDefaultResultOrder("ipv4first");
44
···
82
app.route("/googledrive", googledrive);
83
84
app.route("/apikeys", apikeys);
85
+
86
+
app.route("/public/og", opengraph);
87
88
app.get("/ws", upgradeWebSocket(handleWebsocket));
89
+134
apps/api/src/opengraph/app.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
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
* Learn more at https://developers.cloudflare.com/workers/
12
*/
13
0
0
14
const metadata = {
15
redirect_uris: ['https://rocksky.app/oauth/callback'],
16
response_types: ['code'],
···
116
const mobileUrl = new URL(request.url);
117
mobileUrl.host = 'm.rocksky.app';
118
mobileUrl.hostname = 'm.rocksky.app';
119
-
return fetch(mobileUrl, request);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
120
}
121
122
const proxyUrl = new URL(request.url);
123
proxyUrl.host = 'rocksky.pages.dev';
124
proxyUrl.hostname = 'rocksky.pages.dev';
125
-
return fetch(proxyUrl, request) as any;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
126
},
127
} satisfies ExportedHandler<Env>;
···
11
* Learn more at https://developers.cloudflare.com/workers/
12
*/
13
14
+
import { fetchOgData, HeadMeta, isHtmlResponse, StripMeta } from './html-rewriter';
15
+
16
const metadata = {
17
redirect_uris: ['https://rocksky.app/oauth/callback'],
18
response_types: ['code'],
···
118
const mobileUrl = new URL(request.url);
119
mobileUrl.host = 'm.rocksky.app';
120
mobileUrl.hostname = 'm.rocksky.app';
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 });
138
}
139
140
const proxyUrl = new URL(request.url);
141
proxyUrl.host = 'rocksky.pages.dev';
142
proxyUrl.hostname = 'rocksky.pages.dev';
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 });
161
},
162
} satisfies ExportedHandler<Env>;