Firefox WebExtension (Desktop and Mobile) that lets you share the current tab to Margit.at, frontpage.fyi, etc. with minimal effort.
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}