Client-side, single user live journal backed by AT Protocol
0

Configure Feed

Select the types of activity you want to include in your feed.

bunnylog / oauth.js
7.7 kB 290 lines
1import { BrowserOAuthClient } from "@atproto/oauth-client-browser"; 2import { Agent } from "@atproto/api"; 3import { 4 displayError, 5 now, 6 makeXRPC, 7 toGraphemeSegments, 8 showMigrationDialog, 9 ALLOWED_DIDS, 10 fetchAndDisplayLatestLogs, closeMigrationDialog 11} from "#app"; 12import { navigate } from "#app/router"; 13import { shouldMigrate, migrateRecordsToNewLexicon } from "#app/migrations"; 14 15 16const OAUTH_SCOPES = 17 "atproto repo:app.bsky.feed.post?action=create repo:space.bunniesin.micro.log?action=delete repo:space.bunniesin.log.entry?action=create"; 18const ROOT = document.querySelector("main[data-currentpage]"); 19 20function clientID() { 21 const isLocal = ["localhost", "127.0.0.1"].includes(window.location.hostname); 22 if (isLocal) { 23 // see https://atproto.com/specs/oauth#localhost-client-development 24 return `http://localhost?${new URLSearchParams({ 25 scope: OAUTH_SCOPES, 26 redirect_uri: Object.assign(new URL(window.location.origin), { 27 hostname: "127.0.0.1", 28 }).href, 29 })}`; 30 } 31 return `https://${window.location.host}/oauth-client-metadata.json`; 32} 33 34const CLIENT_ID = clientID(); 35 36let oauthClient; 37export let agent; 38 39async function beforeLogin(identifier) { 40 console.debug( 41 "[OAUTH]", 42 "performing beforeLogin check for handle", 43 identifier, 44 ); 45 46 const res = await fetch( 47 makeXRPC( 48 "com.atproto.identity.resolveHandle", 49 { 50 handle: identifier, 51 }, 52 "https://api.bsky.app", 53 ), 54 ).then(async (res) => await res.json()); 55 56 if (!ALLOWED_DIDS.includes(res.did)) { 57 console.warn("[OAUTH]", "Unauthorized DID", res.did); 58 59 navigate("log-unauthorized"); 60 return false; 61 } 62 63 return true; 64} 65 66async function setupOAuth() { 67 try { 68 oauthClient = await BrowserOAuthClient.load({ 69 clientId: CLIENT_ID, 70 handleResolver: "https://bsky.social", 71 }); 72 73 const result = await oauthClient.init(); 74 75 if (!result) return; 76 77 const { session, state } = result; 78 if (state != null) { 79 console.debug( 80 "[OAUTH]", 81 "Authenticated", 82 session.sub, 83 `(state: ${state})`, 84 ); 85 } else { 86 console.info("[OAUTH]", "Restored session", session.sub); 87 } 88 89 agent = new Agent(session); 90 91 const res = await agent.com.atproto.server.getSession(); 92 if (!res.success) { 93 console.error("[OAUTH]", "Could not acquire session", res); 94 throw new Error(JSON.stringify(res)); 95 } 96 97 console.info("[OAUTH]", "Agent initialized"); 98 ROOT.setAttribute("data-state", "authorized"); 99 if (await shouldMigrate(agent.did)) { 100 showMigrationDialog() 101 } 102 } catch (error) { 103 displayError("oauth", error); 104 } 105} 106 107async function performLogin(identifier, form) { 108 form.querySelectorAll("button").forEach((input) => { 109 input.setAttribute("aria-busy", "true"); 110 input.setAttribute("disabled", true); 111 }); 112 113 try { 114 if (!(await beforeLogin(identifier))) return; 115 116 await oauthClient.signIn(identifier, { 117 state: window.crypto.randomUUID(), 118 signal: new AbortController().signal, 119 }); 120 } catch (err) { 121 displayError("login", err); 122 console.error(err); 123 } finally { 124 form.querySelectorAll("button").forEach((input) => { 125 input.removeAttribute("aria-busy"); 126 input.removeAttribute("disabled"); 127 }); 128 } 129} 130 131function revokeSession() { 132 if (!agent?.did) return; // do not revoke if we aren't logged in lol 133 134 oauthClient.revoke(agent.did); 135 window.location.reload(); 136} 137 138// https://stackoverflow.com/a/17980070 139function strip(html) { 140 var tmp = document.implementation.createHTMLDocument("New").body; 141 tmp.innerHTML = html; 142 return tmp.textContent || tmp.innerText || ""; 143} 144 145/** 146 * @param {number} length 147 * @param {string} content 148 * @param {string} generatedTID 149 * @returns {import("@atproto/api").AppBskyEmbedExternal.External} 150 */ 151function generateEmbedForContent(length, content, generatedTID) { 152 return { 153 $type: "app.bsky.embed.external", 154 external: { 155 uri: `${window.location.href}?log=${generatedTID}`, 156 title: "View in bunny log", 157 description: length > 63 ? content.substring(0, 60) + "..." : "", // Skip description because otherwise it looks weird and repetitive 158 }, 159 }; 160} 161 162/** 163 * @returns {Promise<import("@atproto/api").ComAtprotoRepoCreateRecord.Response> | void} 164 */ 165async function createCrosspostedLog(tid, content) { 166 /** @type {string | string[]} */ 167 let text = toGraphemeSegments(strip(content)); 168 const embedRecord = generateEmbedForContent(length, text.join(""), tid); 169 if (text.length > 300) { 170 text = [...content.slice(0, 296), "..."]; 171 } 172 173 text = text.join(""); 174 175 try { 176 return await agent.com.atproto.repo.createRecord({ 177 repo: agent.did, 178 collection: "app.bsky.feed.post", 179 rkey: tid, 180 validate: true, 181 record: { 182 $type: "app.bsky.feed.post", 183 text, 184 embed: embedRecord, 185 createdAt: new Date().toISOString(), 186 }, 187 }); 188 } catch (err) { 189 displayError("create", err); 190 console.error(err); 191 } 192} 193 194async function crosspost(content, tid) { 195 const crosspost = await createCrosspostedLog(tid, content); 196 if (!crosspost.success) throw new Error(crosspost); 197 198 await agent.com.atproto.repo.createRecord({ 199 repo: agent.did, 200 collection: "space.bunniesin.log.entry", 201 rkey: tid, 202 record: { 203 $type: "space.bunniesin.log.entry", 204 content: content, 205 createdAt: new Date().toISOString(), 206 blueskyPost: { 207 uri: crosspost.data.uri, 208 cid: crosspost.data.cid, 209 }, 210 }, 211 }); 212} 213 214async function createLog(content, form) { 215 form.querySelectorAll("button, input[type=checkbox]").forEach((input) => { 216 input.setAttribute("aria-busy", "true"); 217 input.setAttribute("disabled", true); 218 }); 219 const tid = now(); 220 221 try { 222 if (form.querySelector("#do-crosspost").checked) { 223 await crosspost(content, tid); 224 } else { 225 await agent.com.atproto.repo.createRecord({ 226 repo: agent.did, 227 collection: "space.bunniesin.log.entry", 228 rkey: tid, 229 record: { 230 $type: "space.bunniesin.log.entry", 231 content: content, 232 createdAt: new Date().toISOString(), 233 }, 234 }); 235 } 236 237 navigate("log-preview"); 238 } catch (err) { 239 displayError("create", err); 240 console.error(err); 241 } finally { 242 form.querySelectorAll("button, input[type=checkbox]").forEach((input) => { 243 input.removeAttribute("aria-busy"); 244 input.removeAttribute("disabled"); 245 }); 246 247 // reset form 248 form.querySelector("#log-content").value = ""; 249 } 250} 251 252export async function startMigration() { 253 try { 254 await migrateRecordsToNewLexicon(agent.did); 255 console.info("[MIGRATION]", "Migration done, closing dialog and fetching entries.") 256 await fetchAndDisplayLatestLogs(); 257 closeMigrationDialog(); 258 } catch (err) { 259 displayError("migration", err); 260 } 261} 262 263ROOT.addEventListener("broadcast", (ev) => { 264 console.info("[ATPROTO]", "received", ev); 265 switch (ev.detail.type) { 266 case "destroy": 267 revokeSession(); 268 break; 269 default: 270 console.warn("Unknown broadcasted type", ev.type); 271 break; 272 } 273}); 274 275document.getElementById("log-form").addEventListener("submit", (ev) => { 276 ev.preventDefault(); 277 const form = ROOT.querySelector("form#log-form"); 278 const content = form.querySelector("textarea#log-content").value; 279 280 createLog(content, form); 281}); 282 283document.getElementById("login-form").addEventListener("submit", (ev) => { 284 ev.preventDefault(); 285 const form = ROOT.querySelector("form#login-form"); 286 const identifier = form.querySelector('input[name="handle"]').value; 287 performLogin(identifier, form); 288}); 289 290setupOAuth();