Something went wrong. Try again.
(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension web atproto comments
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492import type { MarginSession, TextSelector, Annotation } from './types';import { apiUrlItem, cachedSessionItem } from './storage';
async function safariProxyFetch(url: string, options: RequestInit = {}): Promise<Response | null> { try { const apiUrl = await getApiUrl(); const tabs = await browser.tabs.query({ url: `${new URL(apiUrl).origin}/*` });
for (const tab of tabs) { if (!tab.id) continue; try { const result = (await browser.tabs.sendMessage(tab.id, { type: 'SAFARI_FETCH', url, options: { method: options.method || 'GET', headers: options.headers || {}, body: options.body, }, })) as { ok: boolean; status: number; body: string } | undefined;
if (result) return new Response(result.body, { status: result.status }); } catch { continue; } } return null; } catch { return null; }}
async function hasApiHostPermission(): Promise<boolean> { try { const apiUrl = await getApiUrl(); return await browser.permissions.contains({ origins: [`${new URL(apiUrl).origin}/*`] }); } catch { return false; }}
async function getApiUrl(): Promise<string> { return await apiUrlItem.getValue();}
async function getSessionCookie(): Promise<string | null> { try { const apiUrl = await getApiUrl();
const readFrom = async (storeId?: string) => { const cookie = await browser.cookies.get({ url: apiUrl, name: 'margin_session', ...(storeId ? { storeId } : {}), }); return cookie?.value || null; };
const defaultValue = await readFrom(); if (defaultValue) return defaultValue;
if (import.meta.env.BROWSER !== 'safari') { let activeStoreId: string | undefined; try { const [activeTab] = await browser.tabs.query({ active: true, currentWindow: true }); activeStoreId = (activeTab as { cookieStoreId?: string } | undefined)?.cookieStoreId; } catch { // ignore: tabs.query can fail in some contexts }
if (activeStoreId) { const value = await readFrom(activeStoreId); if (value) return value; }
try { const stores = await browser.cookies.getAllCookieStores(); for (const store of stores) { if (store.id === activeStoreId) continue; const value = await readFrom(store.id); if (value) return value; } } catch { // ignore: getAllCookieStores may not be supported everywhere } }
return null; } catch (error) { console.error('Get cookie error:', error); return null; }}
async function sessionFromResponse(res: Response): Promise<MarginSession> { if (!res.ok) { await cachedSessionItem.setValue(null); return { authenticated: false }; }
const sessionData = await res.json(); if (!sessionData.did || !sessionData.handle) { await cachedSessionItem.setValue(null); return { authenticated: false }; }
const session: MarginSession = { authenticated: true, did: sessionData.did, handle: sessionData.handle, accessJwt: sessionData.accessJwt, refreshJwt: sessionData.refreshJwt, }; await cachedSessionItem.setValue(session); return session;}
export async function checkSession(): Promise<MarginSession> { try { const apiUrl = await getApiUrl(); const cookie = await getSessionCookie();
if (cookie) { try { const res = await fetch(`${apiUrl}/auth/session`, { headers: { 'X-Session-Token': cookie }, }); return await sessionFromResponse(res); } catch (error) { if (import.meta.env.BROWSER !== 'safari') throw error; } }
if (import.meta.env.BROWSER === 'safari') { const res = await safariProxyFetch(`${apiUrl}/auth/session`); if (res) return await sessionFromResponse(res);
if (!cookie && typeof browser.cookies?.get === 'function' && (await hasApiHostPermission())) { await cachedSessionItem.setValue(null); return { authenticated: false }; } const cached = await cachedSessionItem.getValue(); if (cached?.authenticated) return cached; }
return { authenticated: false }; } catch (error) { console.error('Session check error:', error); return { authenticated: false }; }}
async function apiRequest(path: string, options: RequestInit = {}): Promise<Response> { const apiUrl = await getApiUrl(); const apiPath = path.startsWith('/api') ? path : `/api${path}`; const url = `${apiUrl}${apiPath}`;
const cookie = await getSessionCookie();
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...(options.headers as Record<string, string>), };
if (cookie) { headers['X-Session-Token'] = cookie; }
if (import.meta.env.BROWSER === 'safari') { if (cookie) { try { return await fetch(url, { ...options, headers, credentials: 'include' }); } catch { /* empty */ } } const res = await safariProxyFetch(url, { ...options, headers }); if (res) return res; if (!cookie) { return fetch(url, { ...options, headers, credentials: 'include' }); } return new Response(JSON.stringify({ error: 'Could not reach margin.at' }), { status: 503 }); }
return fetch(url, { ...options, headers, credentials: 'include', });}
async function hashUrl(rawUrl: string): Promise<string> { let toHash: string; try { const parsed = new URL(rawUrl); let host = parsed.host.toLowerCase(); if (host.startsWith('www.')) host = host.slice(4); let normalized = host + parsed.pathname; if (parsed.search) normalized += parsed.search; normalized = normalized.replace(/\/$/, ''); toHash = normalized; } catch { toHash = rawUrl; } const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(toHash)); return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, '0')) .join('');}
export async function getAnnotations( url: string, citedUrls: string[] = [], cacheBust: boolean = false) { try { const apiUrl = await getApiUrl(); const uniqueUrls = [...new Set([url, ...citedUrls])];
const hashes = await Promise.all(uniqueUrls.map(hashUrl));
const params = new URLSearchParams(); hashes.forEach((h) => params.append('h', h)); if (cacheBust) params.append('t', Date.now().toString());
const res = await fetch(`${apiUrl}/api/targets/hash?${params}`); const data = res.ok ? await res.json() : { annotations: [], highlights: [], bookmarks: [] };
const allItems: any[] = []; const seenIds = new Set<string>(); const items = [ ...(data.annotations || []), ...(data.highlights || []), ...(data.bookmarks || []), ]; items.forEach((item: any) => { const id = item.uri || item.id; if (id && !seenIds.has(id)) { seenIds.add(id); allItems.push(item); } });
return allItems; } catch (error) { console.error('Get annotations error:', error); return []; }}
export async function createAnnotation(data: { url: string; text: string; title?: string; selector?: TextSelector; tags?: string[];}): Promise<{ success: boolean; data?: Annotation; error?: string }> { try { const res = await apiRequest('/annotations', { method: 'POST', body: JSON.stringify({ url: data.url, text: data.text, title: data.title, selector: data.selector, tags: data.tags, }), });
if (!res.ok) { const error = await res.text(); return { success: false, error }; }
return { success: true, data: await res.json() }; } catch (error) { return { success: false, error: String(error) }; }}
export async function createBookmark(data: { url: string; title?: string; tags?: string[] }) { try { const res = await apiRequest('/bookmarks', { method: 'POST', body: JSON.stringify({ url: data.url, title: data.title, tags: data.tags }), });
if (!res.ok) { const error = await res.text(); return { success: false, error }; }
return { success: true, data: await res.json() }; } catch (error) { return { success: false, error: String(error) }; }}
export async function createHighlight(data: { url: string; title?: string; selector: TextSelector; color?: string; tags?: string[];}) { try { const res = await apiRequest('/highlights', { method: 'POST', body: JSON.stringify({ url: data.url, title: data.title, selector: data.selector, color: data.color, tags: data.tags, }), });
if (!res.ok) { const error = await res.text(); return { success: false, error }; }
return { success: true, data: await res.json() }; } catch (error) { return { success: false, error: String(error) }; }}
export async function updateHighlightTags(uri: string, tags: string[]) { try { const res = await apiRequest(`/highlights?uri=${encodeURIComponent(uri)}`, { method: 'PUT', body: JSON.stringify({ tags }), }); if (!res.ok) { const error = await res.text(); return { success: false, error }; } return { success: true }; } catch (error) { return { success: false, error: String(error) }; }}
export async function getUserBookmarks(did: string) { try { const res = await apiRequest(`/users/${did}/bookmarks`); if (!res.ok) return []; const data = await res.json(); return data.items || data || []; } catch (error) { console.error('Get bookmarks error:', error); return []; }}
export async function getUserHighlights(did: string) { try { const res = await apiRequest(`/users/${did}/highlights`); if (!res.ok) return []; const data = await res.json(); return data.items || data || []; } catch (error) { console.error('Get highlights error:', error); return []; }}
export async function getUserCollections(did: string) { try { const res = await apiRequest(`/collections?author=${encodeURIComponent(did)}`); if (!res.ok) return []; const data = await res.json(); return data.items || data || []; } catch (error) { console.error('Get collections error:', error); return []; }}
export async function addToCollection(collectionUri: string, annotationUri: string) { try { const res = await apiRequest(`/collections/${encodeURIComponent(collectionUri)}/items`, { method: 'POST', body: JSON.stringify({ annotationUri, position: 0 }), });
if (!res.ok) { const error = await res.text(); return { success: false, error }; }
return { success: true }; } catch (error) { return { success: false, error: String(error) }; }}
export async function getItemCollections(annotationUri: string): Promise<string[]> { try { const res = await apiRequest( `/collections/containing?uri=${encodeURIComponent(annotationUri)}` ); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data) ? data : []; } catch (error) { console.error('Get item collections error:', error); return []; }}
export async function deleteHighlight(uri: string) { try { const rkey = (uri || '').split('/').pop(); if (!rkey) return { success: false, error: 'Invalid URI' };
const res = await apiRequest(`/highlights?rkey=${rkey}`, { method: 'DELETE', });
if (!res.ok) { const error = await res.text(); return { success: false, error }; }
return { success: true }; } catch (error) { return { success: false, error: String(error) }; }}
export async function getUserTags(did: string) { try { const res = await apiRequest(`/users/${did}/tags?limit=50`); if (!res.ok) return []; const data = await res.json(); return (data || []).map((t: { tag: string }) => t.tag); } catch (error) { console.error('Get user tags error:', error); return []; }}
export async function getTrendingTags() { try { const res = await apiRequest('/trending-tags?limit=50'); if (!res.ok) return []; const data = await res.json(); return (data || []).map((t: { tag: string }) => t.tag); } catch (error) { console.error('Get trending tags error:', error); return []; }}
export async function getReplies(uri: string) { try { const res = await apiRequest(`/annotations/${encodeURIComponent(uri)}/replies`); if (!res.ok) return []; const data = await res.json(); return data.items || data || []; } catch (error) { console.error('Get replies error:', error); return []; }}
export async function createReply(data: { parentUri: string; parentCid: string; rootUri: string; rootCid: string; text: string;}) { try { const res = await apiRequest('/replies', { method: 'POST', body: JSON.stringify(data), });
if (!res.ok) { const error = await res.text(); return { success: false, error }; }
return { success: true }; } catch (error) { return { success: false, error: String(error) }; }}