/* Badger - Web application to detect ATProto applications and generate static HTML 'badges' for a user Copyright (C) 2026 Grant Mulholland This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ import * as tiles from "./modules/tiles.js"; import * as tb from "./modules/toolbox.js"; import { toaster } from "./modules/toaster-inator.js"; // dark magic import * as at from '@atcute/client'; import { ok } from '@atcute/client'; import * as cbor from "@atcute/cbor"; import * as cid from "@atcute/cid"; import * as obc from "@atcute/oauth-browser-client"; import oauth from "./oauth.json" with { type: 'json' }; import { makeBadges } from "./modules/badges.js"; const slingshot = new at.Client({ handler: at.simpleFetchHandler({ service: 'https://slingshot.microcosm.blue' }), }); obc.configureOAuth({ metadata: { client_id: oauth.client_id, redirect_uri: oauth.redirect_uris[0] }, identityResolver: { resolve: async (actor) => await ok(slingshot.get('blue.microcosm.identity.resolveMiniDoc', { params: { identifier: actor } })) } }); const main = document.getElementById("main"); const inputs = document.getElementById("inputs"); const id = document.getElementById("id"); const go = document.getElementById("go"); const result = document.getElementById("result"); const initialResult = document.getElementById("initial-result"); const out = document.getElementById("out"); const preview = document.getElementById("preview"); const publishButton = document.getElementById("publish"); const tileResult = document.getElementById("tile-result"); const tileURI = document.getElementById("tile-uri"); const tileLink = document.getElementById("tile-link"); // hidden by default to account for non-JS visitors main.hidden = false; // state to make publication easier /** * @type {import("@atcute/microcosm").BlueMicrocosmIdentityResolveMiniDoc.$output} */ let currentDoc; /** * @type {string} */ let currentOut; inputs.addEventListener("submit", async (e) => { e.preventDefault(); result.hidden = true; id.disabled = true; go.disabled = true; try { const trimmed = id.value.trim(); /** * @type {import("@atcute/microcosm").BlueMicrocosmIdentityResolveMiniDoc.$output} */ const miniDoc = await ok(slingshot.get('blue.microcosm.identity.resolveMiniDoc', { params: { identifier: trimmed } })); const pds = new at.Client({ handler: at.simpleFetchHandler({ service: miniDoc.pds }), }); /** * @type {import("@atcute/atproto").ComAtprotoRepoDescribeRepo.$output} */ const description = await ok(pds.get("com.atproto.repo.describeRepo", { params: { repo: miniDoc.did } })); const badges = await makeBadges(description, miniDoc); currentDoc = miniDoc; const op = `
${badges.join("")}
`; console.log(op); out.textContent = op; preview.innerHTML = op; currentOut = badges; result.hidden = false; initialResult.hidden = false; toaster.info("Created badges!"); } catch (e) { if (e instanceof at.ClientResponseError) { toaster.error(`${e.status} Error: ${e.error} - ${e.description||"(no description)"}`); } else { toaster.error(`Unusual error: ${e}`); } } finally { id.disabled = false; go.disabled = false; } }); publishButton.addEventListener("click", async () => { toaster.info("Starting authorization..."); localStorage.clear(); sessionStorage.clear(); sessionStorage.setItem('doc', JSON.stringify(currentDoc)); sessionStorage.setItem('out', JSON.stringify(currentOut)); const authURL = await obc.createAuthorizationUrl({ target: { type: "account", identifier: currentDoc.did }, scope: oauth.scope }); globalThis.location.assign(authURL); }); // server redirects with params in hash, not search string const params = new URLSearchParams(location.hash.slice(1)); // scrub params from URL to prevent replay history.replaceState(null, '', location.pathname + location.search); if (params.get("code")) { try { toaster.info("Welcome back! Finishing authorization..."); currentDoc = JSON.parse(sessionStorage.getItem('doc')); currentOut = JSON.parse(sessionStorage.getItem('out')); const { session } = await obc.finalizeAuthorization(params); const agent = new obc.OAuthUserAgent(session); const rpc = new at.Client({ handler: agent }); const data = tiles.makeBadgerTile(currentDoc, currentOut); /** * @type {Object.} */ const resRefs = {}; toaster.info("Uploading data..."); for (const key in data.resources) { if (!Object.hasOwn(data.resources, key)) continue; const element = data.resources[key]; const bullshit = new Headers(); bullshit.append("Content-Type", "application/octet-stream"); const {blob} = await ok(rpc.post("com.atproto.repo.uploadBlob", { input: element, headers: bullshit })); resRefs[key] = blob; } const tileData = { "name": data.name, "sizing": { "width": 400, "height": 400 }, "resources": tb.objMap(resRefs, (v, k) => { const src = data.resources[k]; return { "content-type": src.type, "src": v }; }) }; const encoded = cbor.encode(tileData); const tileCID = cid.toString(await cid.create(0x71, encoded)); toaster.info("Creating record..."); const crrsp = await ok(rpc.post("com.atproto.repo.createRecord", { input: { repo: currentDoc.did, collection: "ing.dasl.masl", record: { "$type": "ing.dasl.masl", "createdAt": new Date().toISOString(), "cid": tileCID, "tile": tileData }, } })); toaster.info("Done publishing!"); result.hidden = false; tileResult.hidden = false; tileURI.textContent = crrsp.uri; tileLink.href = `https://webtil.es/browser/#url=${crrsp.uri}`; } catch (e) { if (e instanceof at.ClientResponseError) { toaster.error(`Client Error: ${e.error} - ${e.description||"(no description)"}`); } else { toaster.error(`Unusual error: ${e}`); } } }