Firefox WebExtension (Desktop and Mobile) that lets you share the current tab to Margit.at, frontpage.fyi, etc. with minimal effort.
at main 284 lines 8.3 kB view raw
1const browser = globalThis.browser ?? globalThis.chrome; 2const STORAGE_KEY = "frontpageAuth"; 3const FRONTPAGE_COLLECTION = "fyi.frontpage.feed.post"; 4const RECORD_TYPE = "fyi.frontpage.feed.post"; 5const DEFAULT_MAX_TITLE = 120; 6const DEFAULT_MAX_URL = 2048; 7 8async function getStoredAuth() { 9 const stored = await browser.storage.local.get(STORAGE_KEY); 10 return stored[STORAGE_KEY] ?? null; 11} 12 13async function setStoredAuth(auth) { 14 await browser.storage.local.set({ [STORAGE_KEY]: auth }); 15} 16 17async function clearStoredAuth() { 18 await browser.storage.local.remove(STORAGE_KEY); 19} 20 21async function resolveHandle(handle) { 22 const endpoint = "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle"; 23 const url = `${endpoint}?handle=${encodeURIComponent(handle)}`; 24 const res = await fetch(url); 25 if (!res.ok) { 26 throw new Error(`Handle resolution failed (${res.status})`); 27 } 28 const data = await res.json(); 29 if (!data.did) { 30 throw new Error("Handle resolution response missing DID"); 31 } 32 return data.did; 33} 34 35async function lookupPds(did) { 36 const url = `https://plc.directory/${encodeURIComponent(did)}`; 37 const res = await fetch(url); 38 if (!res.ok) { 39 throw new Error(`PLC lookup failed (${res.status})`); 40 } 41 const doc = await res.json(); 42 const services = Array.isArray(doc.service) ? doc.service : []; 43 const pds = services.find((s) => s.type === "AtprotoPersonalDataServer")?.serviceEndpoint; 44 if (!pds) { 45 throw new Error("Unable to determine personal data server"); 46 } 47 return pds.replace(/\/+$/, ""); 48} 49 50async function createSession({ identifier, password, pds }) { 51 const res = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { 52 method: "POST", 53 headers: { "Content-Type": "application/json" }, 54 body: JSON.stringify({ identifier, password }) 55 }); 56 if (!res.ok) { 57 const errorText = await res.text(); 58 throw new Error(`Login failed (${res.status}): ${errorText || res.statusText}`); 59 } 60 return res.json(); 61} 62 63async function refreshSession(auth) { 64 const res = await fetch(`${auth.pds}/xrpc/com.atproto.server.refreshSession`, { 65 method: "POST", 66 headers: { 67 Authorization: `Bearer ${auth.refreshJwt}` 68 } 69 }); 70 if (!res.ok) { 71 throw new Error(`Session refresh failed (${res.status})`); 72 } 73 const data = await res.json(); 74 const updated = { 75 ...auth, 76 accessJwt: data.accessJwt, 77 refreshJwt: data.refreshJwt, 78 did: data.did ?? auth.did, 79 handle: data.handle ?? auth.handle 80 }; 81 await setStoredAuth(updated); 82 return updated; 83} 84 85function decodeJwt(token) { 86 try { 87 const payloadPart = token.split(".")[1]; 88 const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/"); 89 const padded = 90 normalized + "=".repeat((4 - (normalized.length % 4 || 4)) % 4); 91 const json = atob(padded); 92 return JSON.parse(json); 93 } catch { 94 return null; 95 } 96} 97 98function isExpired(token, skewSeconds = 30) { 99 const payload = decodeJwt(token); 100 if (!payload?.exp) return false; 101 const expiresAt = payload.exp * 1000; 102 return Date.now() + skewSeconds * 1000 >= expiresAt; 103} 104 105async function ensureSession() { 106 let auth = await getStoredAuth(); 107 if (!auth) { 108 throw new Error("Not authenticated. Set up your credentials in the add-on options."); 109 } 110 if (isExpired(auth.accessJwt)) { 111 auth = await refreshSession(auth); 112 } 113 return auth; 114} 115 116async function createFrontpageRecord({ title, url }, authOverride) { 117 const trimmedTitle = (title ?? "").trim().slice(0, DEFAULT_MAX_TITLE); 118 const trimmedUrl = (url ?? "").trim().slice(0, DEFAULT_MAX_URL); 119 if (!trimmedTitle) { 120 throw new Error("Title is required."); 121 } 122 try { 123 new URL(trimmedUrl); 124 } catch { 125 throw new Error("URL is invalid."); 126 } 127 128 const auth = authOverride ?? (await ensureSession()); 129 const body = { 130 repo: auth.did, 131 collection: FRONTPAGE_COLLECTION, 132 record: { 133 $type: RECORD_TYPE, 134 title: trimmedTitle, 135 subject: { $type: "fyi.frontpage.feed.post#urlSubject", url: trimmedUrl }, 136 createdAt: new Date().toISOString() 137 } 138 }; 139 140 const res = await fetch(`${auth.pds}/xrpc/com.atproto.repo.createRecord`, { 141 method: "POST", 142 headers: { 143 Authorization: `Bearer ${auth.accessJwt}`, 144 "Content-Type": "application/json" 145 }, 146 body: JSON.stringify(body) 147 }); 148 149 if (res.status === 401 && !authOverride) { 150 // Attempt one refresh 151 const refreshed = await refreshSession(auth); 152 return createFrontpageRecord({ title: trimmedTitle, url: trimmedUrl }, refreshed); 153 } 154 155 if (!res.ok) { 156 const errorText = await res.text(); 157 throw new Error(`Post failed (${res.status}): ${errorText || res.statusText}`); 158 } 159 160 return res.json(); 161} 162 163const MARGIN_ANNOTATION_COLLECTION = "at.margin.annotation"; 164const MARGIN_HIGHLIGHT_COLLECTION = "at.margin.highlight"; 165 166async function createMarginRecord({ url, title, exact, prefix, suffix, comment }, authOverride) { 167 if (!url) throw new Error("URL is required."); 168 if (!exact) throw new Error("Selected text is required."); 169 170 const auth = authOverride ?? (await ensureSession()); 171 172 const selector = { 173 $type: "at.margin.annotation#textQuoteSelector", 174 exact, 175 ...(prefix ? { prefix } : {}), 176 ...(suffix ? { suffix } : {}) 177 }; 178 179 const target = { 180 $type: "at.margin.annotation#target", 181 source: url, 182 ...(title ? { title } : {}), 183 selector 184 }; 185 186 const generator = { 187 id: "https://github.com/antonmry/atproto_firefox_plugin", 188 name: "Frontpage Submitter" 189 }; 190 191 const hasComment = comment && comment.trim(); 192 const collection = hasComment ? MARGIN_ANNOTATION_COLLECTION : MARGIN_HIGHLIGHT_COLLECTION; 193 194 const record = hasComment 195 ? { 196 $type: MARGIN_ANNOTATION_COLLECTION, 197 motivation: "commenting", 198 body: { value: comment.trim(), format: "text/plain" }, 199 target, 200 generator, 201 createdAt: new Date().toISOString() 202 } 203 : { 204 $type: MARGIN_HIGHLIGHT_COLLECTION, 205 target, 206 generator, 207 createdAt: new Date().toISOString() 208 }; 209 210 const body = { 211 repo: auth.did, 212 collection, 213 record 214 }; 215 216 const res = await fetch(`${auth.pds}/xrpc/com.atproto.repo.createRecord`, { 217 method: "POST", 218 headers: { 219 Authorization: `Bearer ${auth.accessJwt}`, 220 "Content-Type": "application/json" 221 }, 222 body: JSON.stringify(body) 223 }); 224 225 if (res.status === 401 && !authOverride) { 226 const refreshed = await refreshSession(auth); 227 return createMarginRecord({ url, title, exact, prefix, suffix, comment }, refreshed); 228 } 229 230 if (!res.ok) { 231 const errorText = await res.text(); 232 throw new Error(`Post failed (${res.status}): ${errorText || res.statusText}`); 233 } 234 235 return res.json(); 236} 237 238browser.runtime.onMessage.addListener((message) => { 239 switch (message?.type) { 240 case "frontpage-submit": 241 return createFrontpageRecord(message.payload).then( 242 (result) => ({ ok: true, result }), 243 (error) => ({ ok: false, error: error.message }) 244 ); 245 case "margin-submit": 246 return createMarginRecord(message.payload).then( 247 (result) => ({ ok: true, result }), 248 (error) => ({ ok: false, error: error.message }) 249 ); 250 case "frontpage-login": 251 return handleLogin(message.payload).then( 252 () => ({ ok: true }), 253 (error) => ({ ok: false, error: error.message }) 254 ); 255 case "frontpage-logout": 256 return clearStoredAuth().then(() => ({ ok: true })); 257 case "frontpage-get-auth": 258 return getStoredAuth().then((auth) => ({ ok: true, auth: auth ?? null })); 259 default: 260 return false; 261 } 262}); 263 264async function handleLogin(payload) { 265 if (!payload?.handle || !payload?.password) { 266 throw new Error("Handle and app password are required."); 267 } 268 const handle = payload.handle.trim().toLowerCase(); 269 const password = payload.password.trim(); 270 const pds = 271 payload.pds?.trim().replace(/\/+$/, "") || 272 (await lookupPds(await resolveHandle(handle))); 273 const session = await createSession({ identifier: handle, password, pds }); 274 const stored = { 275 handle: session.handle ?? handle, 276 did: session.did, 277 accessJwt: session.accessJwt, 278 refreshJwt: session.refreshJwt, 279 email: session.email ?? null, 280 pds, 281 createdAt: new Date().toISOString() 282 }; 283 await setStoredAuth(stored); 284}