ATProto app badge generator for static web pages
colddark.world/tools/badger/index.html
atproto
web-app
vanilla-js
web-components
oauth
1/*
2Badger - Web application to detect ATProto applications and generate static HTML 'badges' for a user
3Copyright (C) 2026 Grant Mulholland <badger@colddark.world>
4
5This program is free software: you can redistribute it and/or modify
6it under the terms of the GNU General Public License as published by
7the Free Software Foundation, either version 3 of the License, or
8(at your option) any later version.
9
10This program is distributed in the hope that it will be useful,
11but WITHOUT ANY WARRANTY; without even the implied warranty of
12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13GNU General Public License for more details.
14
15You should have received a copy of the GNU General Public License
16along with this program. If not, see <https://www.gnu.org/licenses/>.
17*/
18
19import * as tiles from "./modules/tiles.js";
20import * as tb from "./modules/toolbox.js";
21import { toaster } from "./modules/toaster-inator.js";
22
23// dark magic
24import * as at from '@atcute/client';
25import { ok } from '@atcute/client';
26import * as cbor from "@atcute/cbor";
27import * as cid from "@atcute/cid";
28import * as obc from "@atcute/oauth-browser-client";
29
30import oauth from "./oauth.json" with { type: 'json' };
31import { makeBadges } from "./modules/badges.js";
32
33const slingshot = new at.Client({
34 handler: at.simpleFetchHandler({ service: 'https://slingshot.microcosm.blue' }),
35});
36
37obc.configureOAuth({
38 metadata: {
39 client_id: oauth.client_id,
40 redirect_uri: oauth.redirect_uris[0]
41 },
42 identityResolver: {
43 resolve: async (actor) => await ok(slingshot.get('blue.microcosm.identity.resolveMiniDoc', { params: { identifier: actor } }))
44 }
45});
46
47const main = document.getElementById("main");
48const inputs = document.getElementById("inputs");
49const id = document.getElementById("id");
50const go = document.getElementById("go");
51const result = document.getElementById("result");
52const initialResult = document.getElementById("initial-result");
53const out = document.getElementById("out");
54const preview = document.getElementById("preview");
55
56const publishButton = document.getElementById("publish");
57const tileResult = document.getElementById("tile-result");
58const tileURI = document.getElementById("tile-uri");
59const tileLink = document.getElementById("tile-link");
60
61// hidden by default to account for non-JS visitors
62main.hidden = false;
63
64// state to make publication easier
65/**
66 * @type {import("@atcute/microcosm").BlueMicrocosmIdentityResolveMiniDoc.$output}
67 */
68let currentDoc;
69/**
70 * @type {string}
71 */
72let currentOut;
73
74inputs.addEventListener("submit", async (e) => {
75 e.preventDefault();
76 result.hidden = true;
77 id.disabled = true;
78 go.disabled = true;
79 try {
80 const trimmed = id.value.trim();
81 /**
82 * @type {import("@atcute/microcosm").BlueMicrocosmIdentityResolveMiniDoc.$output}
83 */
84 const miniDoc = await ok(slingshot.get('blue.microcosm.identity.resolveMiniDoc', { params: { identifier: trimmed } }));
85 const pds = new at.Client({
86 handler: at.simpleFetchHandler({ service: miniDoc.pds }),
87 });
88 /**
89 * @type {import("@atcute/atproto").ComAtprotoRepoDescribeRepo.$output}
90 */
91 const description = await ok(pds.get("com.atproto.repo.describeRepo", {
92 params: {
93 repo: miniDoc.did
94 }
95 }));
96 const badges = await makeBadges(description, miniDoc);
97 currentDoc = miniDoc;
98 const op = `<div class="badger-outer">${badges.join("")}</div>`;
99 console.log(op);
100 out.textContent = op;
101 preview.innerHTML = op;
102 currentOut = badges;
103 result.hidden = false;
104 initialResult.hidden = false;
105 toaster.info("Created badges!");
106 } catch (e) {
107 if (e instanceof at.ClientResponseError) {
108 toaster.error(`${e.status} Error: ${e.error} - ${e.description||"(no description)"}`);
109 } else {
110 toaster.error(`Unusual error: ${e}`);
111 }
112 } finally {
113 id.disabled = false;
114 go.disabled = false;
115 }
116});
117
118publishButton.addEventListener("click", async () => {
119 toaster.info("Starting authorization...");
120 localStorage.clear();
121 sessionStorage.clear();
122 sessionStorage.setItem('doc', JSON.stringify(currentDoc));
123 sessionStorage.setItem('out', JSON.stringify(currentOut));
124 const authURL = await obc.createAuthorizationUrl({
125 target: { type: "account", identifier: currentDoc.did },
126 scope: oauth.scope
127 });
128 globalThis.location.assign(authURL);
129});
130
131// server redirects with params in hash, not search string
132const params = new URLSearchParams(location.hash.slice(1));
133
134// scrub params from URL to prevent replay
135history.replaceState(null, '', location.pathname + location.search);
136if (params.get("code")) {
137 try {
138 toaster.info("Welcome back! Finishing authorization...");
139 currentDoc = JSON.parse(sessionStorage.getItem('doc'));
140 currentOut = JSON.parse(sessionStorage.getItem('out'));
141 const { session } = await obc.finalizeAuthorization(params);
142 const agent = new obc.OAuthUserAgent(session);
143 const rpc = new at.Client({ handler: agent });
144
145 const data = tiles.makeBadgerTile(currentDoc, currentOut);
146 /**
147 * @type {Object.<string,import("@atcute/atproto").ComAtprotoRepoUploadBlob.$output>}
148 */
149 const resRefs = {};
150 toaster.info("Uploading data...");
151 for (const key in data.resources) {
152 if (!Object.hasOwn(data.resources, key)) continue;
153 const element = data.resources[key];
154 const bullshit = new Headers();
155 bullshit.append("Content-Type", "application/octet-stream");
156 const {blob} = await ok(rpc.post("com.atproto.repo.uploadBlob", {
157 input: element,
158 headers: bullshit
159 }));
160 resRefs[key] = blob;
161 }
162 const tileData = {
163 "name": data.name,
164 "sizing": {
165 "width": 400,
166 "height": 400
167 },
168 "resources": tb.objMap(resRefs, (v, k) => {
169 const src = data.resources[k];
170 return {
171 "content-type": src.type,
172 "src": v
173 };
174 })
175 };
176
177 const encoded = cbor.encode(tileData);
178 const tileCID = cid.toString(await cid.create(0x71, encoded));
179 toaster.info("Creating record...");
180 const crrsp = await ok(rpc.post("com.atproto.repo.createRecord", {
181 input: {
182 repo: currentDoc.did,
183 collection: "ing.dasl.masl",
184 record: {
185 "$type": "ing.dasl.masl",
186 "createdAt": new Date().toISOString(),
187 "cid": tileCID,
188 "tile": tileData
189 },
190 }
191 }));
192 toaster.info("Done publishing!");
193 result.hidden = false;
194 tileResult.hidden = false;
195 tileURI.textContent = crrsp.uri;
196 tileLink.href = `https://webtil.es/browser/#url=${crrsp.uri}`;
197 } catch (e) {
198 if (e instanceof at.ClientResponseError) {
199 toaster.error(`Client Error: ${e.error} - ${e.description||"(no description)"}`);
200 } else {
201 toaster.error(`Unusual error: ${e}`);
202 }
203 }
204}