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