Resolve your Bluesky avatar with a human URL
silhouette.town
atproto
deno
1import { STATUS_CODE, STATUS_TEXT } from "@std/http";
2import { Client, simpleFetchHandler } from "@atcute/client";
3import { is } from "@atcute/lexicons";
4import { isActorIdentifier } from "@atcute/lexicons/syntax";
5import { AppBskyActorProfile } from "@atcute/bluesky";
6
7const ROOT = new URLPattern({ pathname: "/" });
8const IDENTIFIER_ROUTE = new URLPattern({ pathname: "/avatar/:identifier" });
9const slingshot = new Client({
10 handler: simpleFetchHandler({ service: "https://slingshot.microcosm.blue" }),
11});
12
13function generateSpiel(origin: string) {
14 return `<h1>
15 silhouette
16</h1>
17
18<p>
19 Quickly get the URL to your Bluesky profile photo using an identifier (handle or DID). Go to:
20</p>
21
22<p>
23 <code>${origin}/avatar/<your-identifier></code>
24</p>
25
26<p>
27 For example:
28 <a href="${origin}/avatar/graham.systems">
29 ${origin}/avatar/graham.systems
30 </a>
31</p>`;
32}
33
34// Algorithm:
35// 1. Resolve minidoc from identifier in URL
36// 2. Resolve Bluesky actor profile from PDS
37// 3. Return 303 with profile picture blob URL
38
39export async function handler(req: Request): Promise<Response> {
40 if (ROOT.exec(req.url)) {
41 const url = new URL(req.url);
42 return new Response(generateSpiel(url.origin), {
43 status: STATUS_CODE.OK,
44 statusText: STATUS_TEXT[STATUS_CODE.OK],
45 headers: {
46 "Content-Type": "text/html",
47 },
48 });
49 }
50
51 const match = IDENTIFIER_ROUTE.exec(req.url);
52
53 if (!match) {
54 return new Response(null, {
55 status: STATUS_CODE.NotFound,
56 statusText: STATUS_TEXT[STATUS_CODE.NotFound],
57 });
58 } else if (req.method !== "GET") {
59 return new Response(null, {
60 status: STATUS_CODE.MethodNotAllowed,
61 statusText: STATUS_TEXT[STATUS_CODE.MethodNotAllowed],
62 });
63 }
64
65 const identifier = match.pathname.groups.identifier;
66
67 if (!isActorIdentifier(identifier)) {
68 return Response.json({
69 identifier,
70 message: `invalid identifier ${identifier}`,
71 }, {
72 status: STATUS_CODE.BadRequest,
73 statusText: STATUS_TEXT[STATUS_CODE.BadRequest],
74 });
75 }
76
77 const minidoc = await slingshot.get(
78 "com.bad-example.identity.resolveMiniDoc",
79 {
80 params: {
81 identifier,
82 },
83 },
84 );
85
86 if (!minidoc.ok) {
87 return Response.json({
88 identifier,
89 message: `failed to resolve identifier ${identifier}`,
90 }, {
91 status: STATUS_CODE.InternalServerError,
92 statusText: STATUS_TEXT[STATUS_CODE.InternalServerError],
93 });
94 } else if (minidoc.status > 400) {
95 return Response.json({
96 identifier,
97 message: `error from Slingshot`,
98 error: minidoc.data,
99 }, {
100 status: STATUS_CODE.InternalServerError,
101 statusText: STATUS_TEXT[STATUS_CODE.InternalServerError],
102 });
103 }
104
105 const pds = new Client({
106 handler: simpleFetchHandler({ service: minidoc.data.pds }),
107 });
108
109 const record = await pds.get("com.atproto.repo.getRecord", {
110 params: {
111 collection: "app.bsky.actor.profile",
112 rkey: "self",
113 repo: minidoc.data.did,
114 },
115 });
116
117 if (!record.ok || record.status > 400) {
118 return Response.json({
119 identifier,
120 pds: minidoc.data.pds,
121 message: `failed to resolve app.bsky.actor.profile for ${identifier}`,
122 }, {
123 status: record.status || STATUS_CODE.InternalServerError,
124 statusText: STATUS_TEXT[
125 STATUS_CODE.InternalServerError
126 ],
127 });
128 }
129
130 const profile = record.data.value;
131 if (!is(AppBskyActorProfile.mainSchema, profile)) {
132 return Response.json({
133 identifier,
134 pds: minidoc.data.pds,
135 message: `profile record does not match app.bsky.actor.profile`,
136 profile,
137 }, {
138 status: STATUS_CODE.PreconditionFailed,
139 statusText: STATUS_TEXT[STATUS_CODE.PreconditionFailed],
140 });
141 } else if (!profile.avatar) {
142 return Response.json({
143 identifier,
144 pds: minidoc.data.pds,
145 message: "profile has no avatar",
146 }, {
147 status: STATUS_CODE.NotFound,
148 statusText: STATUS_TEXT[STATUS_CODE.NotFound],
149 });
150 }
151
152 const blobUrl = new URL("/xrpc/com.atproto.sync.getBlob", minidoc.data.pds);
153 const cid = "ref" in profile.avatar
154 ? profile.avatar.ref.$link
155 : profile.avatar.cid;
156
157 blobUrl.searchParams.set("did", minidoc.data.did);
158 blobUrl.searchParams.set("cid", cid);
159
160 return Response.redirect(blobUrl, STATUS_CODE.SeeOther);
161}
162
163if (import.meta.main) {
164 Deno.serve(handler);
165}