Resolve your Bluesky avatar with a human URL silhouette.town
atproto deno
at main 165 lines 4.4 kB view raw
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/&lt;your-identifier&gt;</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}