ATProto app badge generator for static web pages colddark.world/tools/badger/index.html
atproto web-app vanilla-js web-components oauth
at trunk 204 lines 6.6 kB view raw
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}