Various AT Protocol integrations with obsidian
20
fork

Configure Feed

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

add session store / refactor

handle missing block text

+361 -243
+3 -3
src/commands/publishDocument.ts
··· 189 189 doc: SiteStandardDocument.Main, 190 190 existingUri?: ResourceUri, 191 191 ) { 192 - if (!plugin.client) { 192 + if (!await plugin.checkAuth()) { 193 193 throw new Error("Client not initialized"); 194 194 } 195 195 196 196 const response = existingUri 197 - ? await putDocument(plugin.client, plugin.settings.identifier, existingUri, doc) 198 - : await createDocument(plugin.client, plugin.settings.identifier, doc); 197 + ? await putDocument(plugin.client, plugin.settings.did!, existingUri, doc) 198 + : await createDocument(plugin.client, plugin.settings.did!, doc); 199 199 200 200 if (!response.ok) { 201 201 throw new Error(`Failed to publish: ${response.status}`);
+4 -4
src/components/cardDetailModal.ts
··· 85 85 } 86 86 87 87 private async handleAddNote() { 88 - if (!this.plugin.client || !this.noteInput) return; 88 + if (!this.plugin.client.loggedIn || !this.noteInput) return; 89 89 90 90 const text = this.noteInput.value.trim(); 91 91 if (!text) { ··· 96 96 try { 97 97 await createNoteCard( 98 98 this.plugin.client, 99 - this.plugin.settings.identifier, 99 + this.plugin.settings.did!, 100 100 text, 101 101 { uri: this.item.getUri(), cid: this.item.getCid() } 102 102 ); ··· 111 111 } 112 112 113 113 private async handleDeleteNote(noteUri: string) { 114 - if (!this.plugin.client) return; 114 + if (!this.plugin.client.loggedIn) return; 115 115 116 116 const rkey = noteUri.split("/").pop(); 117 117 if (!rkey) { ··· 122 122 try { 123 123 await deleteRecord( 124 124 this.plugin.client, 125 - this.plugin.settings.identifier, 125 + this.plugin.settings.did!, 126 126 "network.cosmik.card", 127 127 rkey 128 128 );
+1 -1
src/components/cardForm.ts
··· 50 50 // new Notice("Please enter some text"); 51 51 // return; 52 52 // } 53 - // await ok(createNoteCard(this.plugin.client!, this.plugin.settings.identifier, text)); 53 + // await ok(createNoteCard(this.plugin.client!, this.plugin.settings.did!, text)); 54 54 // new Notice("Card created successfully!"); 55 55 // this.close(); 56 56 // };
+1 -1
src/components/createCollectionModal.ts
··· 81 81 try { 82 82 await createCollection( 83 83 this.plugin.client, 84 - this.plugin.settings.identifier, 84 + this.plugin.settings.did!, 85 85 name, 86 86 descInput.value.trim() 87 87 );
+1 -1
src/components/createMarginCollectionModal.ts
··· 90 90 try { 91 91 await createMarginCollection( 92 92 this.plugin.client, 93 - this.plugin.settings.identifier, 93 + this.plugin.settings.did!, 94 94 name, 95 95 descInput.value.trim() || undefined, 96 96 iconInput.value.trim() || undefined
+1 -1
src/components/createTagModal.ts
··· 73 73 try { 74 74 await createTag( 75 75 this.plugin.client, 76 - this.plugin.settings.identifier, 76 + this.plugin.settings.did!, 77 77 value 78 78 ); 79 79
+3 -3
src/components/editBookmarkModal.ts
··· 40 40 const loading = contentEl.createEl("p", { text: "Loading..." }); 41 41 42 42 try { 43 - const bookmarksResp = await getBookmarks(this.plugin.client, this.plugin.settings.identifier); 43 + const bookmarksResp = await getBookmarks(this.plugin.client, this.plugin.settings.did!); 44 44 loading.remove(); 45 45 46 46 const bookmarks = (bookmarksResp.ok ? bookmarksResp.data.records : []) as unknown as BookmarkRecord[]; ··· 172 172 173 173 await deleteRecord( 174 174 this.plugin.client, 175 - this.plugin.settings.identifier, 175 + this.plugin.settings.did!, 176 176 "community.lexicon.bookmarks.bookmark", 177 177 rkey 178 178 ); ··· 216 216 217 217 await putRecord( 218 218 this.plugin.client, 219 - this.plugin.settings.identifier, 219 + this.plugin.settings.did!, 220 220 "community.lexicon.bookmarks.bookmark", 221 221 rkey, 222 222 updatedRecord
+6 -6
src/components/editCardModal.ts
··· 53 53 54 54 try { 55 55 const [collectionsResp, linksResp] = await Promise.all([ 56 - getCollections(this.plugin.client, this.plugin.settings.identifier), 57 - getCollectionLinks(this.plugin.client, this.plugin.settings.identifier), 56 + getCollections(this.plugin.client, this.plugin.settings.did!), 57 + getCollectionLinks(this.plugin.client, this.plugin.settings.did!), 58 58 ]); 59 59 60 60 loading.remove(); ··· 162 162 163 163 await deleteRecord( 164 164 this.plugin.client, 165 - this.plugin.settings.identifier, 165 + this.plugin.settings.did!, 166 166 "network.cosmik.card", 167 167 rkey 168 168 ); ··· 202 202 if (rkey) { 203 203 await deleteRecord( 204 204 this.plugin.client, 205 - this.plugin.settings.identifier, 205 + this.plugin.settings.did!, 206 206 "network.cosmik.collectionLink", 207 207 rkey 208 208 ); ··· 216 216 217 217 const collectionResp = await getRecord( 218 218 this.plugin.client, 219 - this.plugin.settings.identifier, 219 + this.plugin.settings.did!, 220 220 "network.cosmik.collection", 221 221 collectionRkey 222 222 ); ··· 225 225 226 226 await createCollectionLink( 227 227 this.plugin.client, 228 - this.plugin.settings.identifier, 228 + this.plugin.settings.did!, 229 229 this.cardUri, 230 230 this.cardCid, 231 231 state.collection.uri,
+7 -7
src/components/editMarginBookmarkModal.ts
··· 53 53 54 54 try { 55 55 const [collectionsResp, itemsResp, bookmarksResp] = await Promise.all([ 56 - getMarginCollections(this.plugin.client, this.plugin.settings.identifier), 57 - getMarginCollectionItems(this.plugin.client, this.plugin.settings.identifier), 58 - getMarginBookmarks(this.plugin.client, this.plugin.settings.identifier), 56 + getMarginCollections(this.plugin.client, this.plugin.settings.did!), 57 + getMarginCollectionItems(this.plugin.client, this.plugin.settings.did!), 58 + getMarginBookmarks(this.plugin.client, this.plugin.settings.did!), 59 59 ]); 60 60 61 61 loading.remove(); ··· 227 227 228 228 await deleteRecord( 229 229 this.plugin.client, 230 - this.plugin.settings.identifier, 230 + this.plugin.settings.did!, 231 231 "at.margin.bookmark", 232 232 rkey 233 233 ); ··· 271 271 272 272 await putRecord( 273 273 this.plugin.client, 274 - this.plugin.settings.identifier, 274 + this.plugin.settings.did!, 275 275 "at.margin.bookmark", 276 276 rkey, 277 277 updatedRecord ··· 286 286 if (linkRkey) { 287 287 await deleteRecord( 288 288 this.plugin.client, 289 - this.plugin.settings.identifier, 289 + this.plugin.settings.did!, 290 290 "at.margin.collectionItem", 291 291 linkRkey 292 292 ); ··· 297 297 for (const state of collectionsToAdd) { 298 298 await createMarginCollectionItem( 299 299 this.plugin.client, 300 - this.plugin.settings.identifier, 300 + this.plugin.settings.did!, 301 301 this.record.uri, 302 302 state.collection.uri 303 303 );
+1 -1
src/components/loginMessage.ts
··· 1 1 export function renderLoginMessage(container: HTMLElement) { 2 2 const message = container.createEl("div", { cls: "atmosphere-auth-required" }); 3 - message.createEl("p", { text: "Please log in by opening Atmosphere settings" }); 3 + message.createEl("p", { text: "Please log in by opening settings" }); 4 4 }
+1 -1
src/components/selectPublicationModal.ts
··· 34 34 const loading = contentEl.createEl("p", { text: "Loading publications..." }); 35 35 36 36 try { 37 - const response = await getPublications(this.plugin.client, this.plugin.settings.identifier); 37 + const response = await getPublications(this.plugin.client, this.plugin.settings.did!); 38 38 loading.remove(); 39 39 40 40 let pubs = response.records
+48 -14
src/lib/client.ts
··· 3 3 import { isActorIdentifier } from "@atcute/lexicons/syntax"; 4 4 import { ResolvedActor } from "@atcute/identity-resolver"; 5 5 import type { OAuthSession } from "@atcute/oauth-node-client"; 6 + import { OAuthHandler } from "./oauth/oauth"; 7 + import { OAuthSessionStore } from "./oauth/oauthStore"; 6 8 7 9 export class ATClient extends Client { 8 - hh: Handler; 10 + private hh: Handler; 11 + actor?: ResolvedActor 9 12 10 - constructor(session: OAuthSession) { 11 - const hh = new Handler(session); 13 + constructor(store: OAuthSessionStore) { 14 + const oauth = new OAuthHandler(store); 15 + const hh = new Handler(oauth); 12 16 super({ handler: hh }); 13 17 this.hh = hh; 14 18 } 15 19 16 20 get loggedIn(): boolean { 17 - return !!this.hh.session.did; 21 + return (!!this.actor?.did && !!this.hh.session?.did) 22 + } 23 + 24 + async login(identifier: string): Promise<void> { 25 + await this.hh.login(identifier); 26 + this.actor = await this.hh.getActor(this.hh.session!.did); 18 27 } 19 28 20 - get session() { 21 - return { did: this.hh.session.did }; 29 + async restoreSession(did: string): Promise<void> { 30 + await this.hh.restoreSession(did); 31 + this.actor = await this.hh.getActor(did); 32 + } 33 + 34 + async logout(identifier: string): Promise<void> { 35 + await this.hh.logout(identifier); 22 36 } 23 37 24 38 async getActor(identifier: string): Promise<ResolvedActor> { ··· 30 44 * Custom handler that wraps OAuthSession and adds PDS routing logic 31 45 */ 32 46 export class Handler implements FetchHandlerObject { 33 - session: OAuthSession; 34 47 cache: Cache; 48 + oauth: OAuthHandler; 49 + session?: OAuthSession 50 + actor?: ResolvedActor; 35 51 36 - constructor(session: OAuthSession) { 37 - this.session = session; 52 + constructor(oauth: OAuthHandler) { 53 + this.oauth = oauth; 38 54 this.cache = new Cache(5 * 60 * 1000); // 5 minutes TTL 55 + } 56 + 57 + async login(identifier: string): Promise<void> { 58 + const session = await this.oauth.authorize(identifier); 59 + this.session = session; 60 + } 61 + async restoreSession(did: string): Promise<void> { 62 + const session = await this.oauth.restore(did); 63 + this.session = session; 64 + } 65 + async logout(identifier: string): Promise<void> { 66 + await this.oauth.revoke(identifier); 67 + this.session = undefined; 39 68 } 40 69 41 70 async getActor(identifier: string): Promise<ResolvedActor> { ··· 50 79 this.cache.set(key, res); 51 80 return res; 52 81 } catch (e) { 53 - console.error("Error resolving actor:", e); 54 - throw new Error("Failed to resolve actor: " + JSON.stringify(identifier)); 82 + throw new Error(`Failed to resolve actor ${identifier}:` + JSON.stringify(e)); 55 83 } 56 84 } else { 57 - throw new Error("Invalid actor identifier: " + JSON.stringify(identifier)); 85 + throw new Error("Invalid actor identifier: " + identifier); 58 86 } 59 87 } 60 88 ··· 65 93 return null; 66 94 } 67 95 68 - const own = (repo === this.session.did); 96 + const own = (repo === this.session?.did) 69 97 if (!own) { 70 98 // resolve to get user's PDS 71 99 const actor = await this.getActor(repo); ··· 87 115 88 116 const pds = await this.getPDS(pathname); 89 117 if (pds) { 118 + // use configureable public fetch for external PDS 90 119 const sfh = simpleFetchHandler({ service: pds }); 91 120 resp = await sfh(pathname, init); 121 + } else if (this.session) { 122 + // oauth handler if we are logged in 123 + resp = await this.session.handle(pathname, init); 92 124 } else { 93 - resp = await this.session.handle(pathname, init); 125 + // default public fetch to bsky 126 + const sfh = simpleFetchHandler({ service: "https://bsky.social" }); 127 + resp = await sfh(pathname, init); 94 128 } 95 129 96 130 if (init?.method?.toLowerCase() === "get" && resp.ok) {
+1 -1
src/lib/markdown/index.ts
··· 45 45 } 46 46 47 47 export function cleanPlaintext(text: string): string { 48 - return text.trim(); 48 + return text ? text.trim() : ""; 49 49 } 50 50 51 51 export type { Root, RootContent };
-160
src/lib/oauth.ts
··· 1 - import * as http from 'http'; 2 - import { OAuthClient, MemoryStore, type StoredState, type OAuthSession } from '@atcute/oauth-node-client'; 3 - import { compositeResolver } from './identity'; 4 - import { Notice } from 'obsidian'; 5 - 6 - const TEN_MINUTES_MS = 10 * 60_000; 7 - 8 - const html = `<!doctype html> 9 - <html> 10 - <head> 11 - <meta charset="UTF-8"> 12 - <title>Authentication Successful</title> 13 - <style> 14 - body { 15 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 16 - display: flex; 17 - align-items: center; 18 - justify-content: center; 19 - min-height: 100vh; 20 - margin: 0; 21 - background: #f0f9ff; 22 - } 23 - .container { 24 - text-align: center; 25 - padding: 2rem; 26 - background: white; 27 - border-radius: 8px; 28 - box-shadow: 0 2px 8px rgba(0,0,0,0.1); 29 - } 30 - h1 { color: #0ea5e9; margin: 0 0 1rem 0; } 31 - p { color: #6b7280; margin: 0; } 32 - </style> 33 - </head> 34 - <body> 35 - <div class="container"> 36 - <h1>✅ Authenticated!</h1> 37 - <p>You can close this window and return to Obsidian.</p> 38 - </div> 39 - </body> 40 - </html>` 41 - 42 - export class OauthServer { 43 - private server: http.Server | null = null; 44 - private port: number = 0; 45 - private redirectUri: string = ''; 46 - private oauth: OAuthClient | null = null; 47 - private resolveCallback: ((value: URLSearchParams) => void) | null = null; 48 - private rejectCallback: ((reason?: any) => void) | null = null; 49 - private timeout: NodeJS.Timeout | null = null; 50 - 51 - private async startServer(): Promise<string> { 52 - if (this.server) { 53 - return this.redirectUri; 54 - } 55 - 56 - return new Promise((resolve, reject) => { 57 - this.server = http.createServer((req, res) => { 58 - const url = new URL(req.url!, `http://127.0.0.1:${this.port}`); 59 - 60 - if (url.pathname === '/callback') { 61 - if (this.resolveCallback) { 62 - this.resolveCallback(url.searchParams); 63 - } 64 - res.writeHead(200, { 'Content-Type': 'text/html' }); 65 - res.end(html); 66 - return; 67 - } 68 - 69 - res.writeHead(404, { 'Content-Type': 'text/plain' }); 70 - res.end('Not Found'); 71 - }); 72 - 73 - this.server.on('error', (err: NodeJS.ErrnoException) => { 74 - console.error('Oauth callback server error:', err); 75 - reject(err); 76 - }); 77 - 78 - this.server.on('listening', () => { 79 - const address = this.server?.address(); 80 - if (address && typeof address === 'object') { 81 - this.port = address.port; 82 - this.redirectUri = `http://127.0.0.1:${this.port}/callback`; 83 - console.log(`OAuth callback server listening on ${this.redirectUri}`); 84 - resolve(this.redirectUri); 85 - } else { 86 - reject(new Error('Failed to get server address')); 87 - } 88 - }); 89 - // use random port number 90 - this.server.listen(0, '127.0.0.1'); 91 - }); 92 - } 93 - 94 - async authorize(identifier: string): Promise<OAuthSession> { 95 - const redirectUri = await this.startServer(); 96 - 97 - // create oauth client with redirect based on the started server 98 - this.oauth = new OAuthClient({ 99 - metadata: { 100 - redirect_uris: [redirectUri], 101 - scope: 'atproto include:at.margin.authFull repo:site.standard.document repo:network.cosmik.card repo:network.cosmik.collection repo:network.cosmik.collectionLink', 102 - }, 103 - actorResolver: compositeResolver, 104 - stores: { 105 - sessions: new MemoryStore({ maxSize: 10 }), 106 - states: new MemoryStore<string, StoredState>({ 107 - maxSize: 10, 108 - ttl: TEN_MINUTES_MS, 109 - ttlAutopurge: true, 110 - }), 111 - }, 112 - }); 113 - 114 - const deferred = new Promise<URLSearchParams>((resolve, reject) => { 115 - this.resolveCallback = resolve; 116 - this.rejectCallback = reject; 117 - }); 118 - 119 - this.timeout = setTimeout(() => { 120 - if (this.rejectCallback) { 121 - this.rejectCallback(new Error('OAuth callback timed out after 5 minutes')); 122 - } 123 - this.cleanup(); 124 - }, 5 * 60_000); 125 - 126 - const { url } = await this.oauth.authorize({ 127 - target: { type: 'account', identifier: identifier as any }, 128 - redirectUri, 129 - }); 130 - window.open(url.href, '_blank'); 131 - 132 - new Notice('Continue login in the browser') 133 - 134 - const params = await deferred; 135 - if (this.timeout) { 136 - clearTimeout(this.timeout); 137 - this.timeout = null; 138 - } 139 - 140 - const { session } = await this.oauth.callback(params, { redirectUri }); 141 - // Clean up server after a short delay 142 - setTimeout(() => this.cleanup(), 2000); 143 - return session; 144 - } 145 - 146 - private cleanup(): void { 147 - if (this.server) { 148 - this.server.close(); 149 - this.server = null; 150 - } 151 - 152 - if (this.timeout) { 153 - clearTimeout(this.timeout); 154 - this.timeout = null; 155 - } 156 - 157 - this.resolveCallback = null; 158 - this.rejectCallback = null; 159 - } 160 - }
+201
src/lib/oauth/oauth.ts
··· 1 + import * as http from 'http'; 2 + import { OAuthClient, MemoryStore, type StoredState, type OAuthSession, } from '@atcute/oauth-node-client'; 3 + import { compositeResolver } from 'lib/identity'; 4 + import { Notice } from 'obsidian'; 5 + import { OAuthSessionStore } from './oauthStore'; 6 + import { isDid } from "@atcute/lexicons/syntax"; 7 + 8 + const TEN_MINUTES_MS = 10 * 60_000; 9 + 10 + const html = `<!doctype html> 11 + <html> 12 + <head> 13 + <meta charset="UTF-8"> 14 + <title>Authentication Successful</title> 15 + <style> 16 + body { 17 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 18 + display: flex; 19 + align-items: center; 20 + justify-content: center; 21 + min-height: 100vh; 22 + margin: 0; 23 + background: #f0f9ff; 24 + } 25 + .container { 26 + text-align: center; 27 + padding: 2rem; 28 + background: white; 29 + border-radius: 8px; 30 + box-shadow: 0 2px 8px rgba(0,0,0,0.1); 31 + } 32 + h1 { color: #0ea5e9; margin: 0 0 1rem 0; } 33 + p { color: #6b7280; margin: 0; } 34 + </style> 35 + </head> 36 + <body> 37 + <div class="container"> 38 + <h1>✅ Authenticated!</h1> 39 + <p>You can close this window and return to Obsidian.</p> 40 + </div> 41 + </body> 42 + </html>` 43 + 44 + 45 + type CallbackResult = { 46 + redirectUri: string; 47 + waitForCallback: Promise<URLSearchParams>; 48 + } 49 + 50 + export class OAuthServer { 51 + private server: http.Server | null = null; 52 + private port: number = 0; 53 + private redirectUri: string = ''; 54 + private resolveCallback: ((value: URLSearchParams) => void) | null = null; 55 + private rejectCallback: ((reason?: Error) => void) | null = null; 56 + private timeout: NodeJS.Timeout | null = null; 57 + sessionStore: OAuthSessionStore; 58 + 59 + async start(): Promise<CallbackResult> { 60 + if (this.server) { 61 + const wait = new Promise<URLSearchParams>((resolve, reject) => { 62 + this.resolveCallback = resolve; 63 + this.rejectCallback = reject; 64 + }) 65 + return { redirectUri: this.redirectUri, waitForCallback: wait }; 66 + } 67 + 68 + const redirectUri = await this.startServer(); 69 + 70 + const waitCallback = new Promise<URLSearchParams>((resolve, reject) => { 71 + this.resolveCallback = resolve; 72 + this.rejectCallback = reject; 73 + this.timeout = setTimeout(() => { 74 + if (this.rejectCallback) { 75 + this.rejectCallback(new Error('OAuth callback timed out after 5 minutes')); 76 + } 77 + this.cleanup(); 78 + }, 5 * 60_000); 79 + }) 80 + 81 + return { redirectUri, waitForCallback: waitCallback }; 82 + } 83 + 84 + private async startServer(): Promise<string> { 85 + return new Promise((resolve, reject) => { 86 + this.server = http.createServer((req, res) => { 87 + const url = new URL(req.url!, `http://127.0.0.1:${this.port}`); 88 + 89 + if (url.pathname === '/callback') { 90 + if (this.resolveCallback) { 91 + this.resolveCallback(url.searchParams); 92 + } 93 + res.writeHead(200, { 'Content-Type': 'text/html' }); 94 + res.end(html); 95 + return; 96 + } 97 + 98 + res.writeHead(404, { 'Content-Type': 'text/plain' }); 99 + res.end('Not Found'); 100 + }); 101 + 102 + this.server.on('error', (err: NodeJS.ErrnoException) => { 103 + console.error('Oauth callback server error:', err); 104 + reject(err); 105 + }); 106 + 107 + this.server.on('listening', () => { 108 + const address = this.server?.address(); 109 + if (address && typeof address === 'object') { 110 + this.port = address.port; 111 + this.redirectUri = `http://127.0.0.1:${this.port}/callback`; 112 + resolve(this.redirectUri); 113 + } else { 114 + reject(new Error('Failed to get server address')); 115 + } 116 + }); 117 + // use random port number 118 + this.server.listen(0, '127.0.0.1'); 119 + }); 120 + } 121 + cleanup(): void { 122 + if (this.server) { 123 + this.server.close(); 124 + this.server = null; 125 + } 126 + 127 + if (this.timeout) { 128 + clearTimeout(this.timeout); 129 + this.timeout = null; 130 + } 131 + 132 + this.resolveCallback = null; 133 + this.rejectCallback = null; 134 + } 135 + } 136 + 137 + 138 + export class OAuthHandler { 139 + private oauth: OAuthClient 140 + private server: OAuthServer; 141 + private sessionStore: OAuthSessionStore; 142 + 143 + constructor(sessionStore: OAuthSessionStore) { 144 + this.server = new OAuthServer(); 145 + this.sessionStore = sessionStore; 146 + // Initialize OAuth client immediately so restore() works 147 + this.initClient('http://127.0.0.1/callback'); 148 + } 149 + 150 + initClient(redirectUri: string): void { 151 + this.oauth = new OAuthClient({ 152 + metadata: { 153 + redirect_uris: [redirectUri], // updated after starting server 154 + scope: 'atproto include:at.margin.authFull repo:site.standard.document repo:network.cosmik.card repo:network.cosmik.collection repo:network.cosmik.collectionLink', 155 + }, 156 + actorResolver: compositeResolver, 157 + stores: { 158 + sessions: this.sessionStore, 159 + states: new MemoryStore<string, StoredState>({ 160 + maxSize: 10, 161 + ttl: TEN_MINUTES_MS, 162 + ttlAutopurge: true, 163 + }), 164 + }, 165 + }); 166 + } 167 + 168 + 169 + async authorize(identifier: string): Promise<OAuthSession> { 170 + const result = await this.server.start(); 171 + // client must be created after starting server to use proper redirect 172 + this.initClient(result.redirectUri); 173 + 174 + const { url } = await this.oauth!.authorize({ 175 + target: { type: 'account', identifier: identifier as any }, 176 + redirectUri: result.redirectUri, 177 + }); 178 + 179 + window.open(url.href, '_blank'); 180 + new Notice('Continue login in the browser') 181 + 182 + const params = await result.waitForCallback; 183 + 184 + const { session } = await this.oauth!.callback(params, { redirectUri: result.redirectUri }); 185 + return session; 186 + } 187 + 188 + async restore(did: string): Promise<OAuthSession> { 189 + if (!isDid(did)) { 190 + throw new Error("Invalid DID: " + did); 191 + } 192 + return await this.oauth.restore(did) 193 + } 194 + 195 + async revoke(did: string): Promise<void> { 196 + if (!isDid(did)) { 197 + throw new Error("Invalid DID: " + did); 198 + } 199 + await this.oauth.revoke(did); 200 + } 201 + }
+38
src/lib/oauth/oauthStore.ts
··· 1 + import { Store, StoredSession } from "@atcute/oauth-node-client"; 2 + import { Did } from "@atcute/lexicons/syntax"; 3 + import AtmospherePlugin from "main"; 4 + 5 + 6 + export class OAuthSessionStore implements Store<Did, StoredSession> { 7 + plugin: AtmospherePlugin; 8 + 9 + constructor(plugin: AtmospherePlugin) { 10 + this.plugin = plugin; 11 + } 12 + 13 + async get(key: Did): Promise<StoredSession | undefined> { 14 + const sessions = this.plugin.settings.oauth.sessions ?? {}; 15 + return sessions[key]; 16 + } 17 + 18 + async set(key: Did, value: StoredSession): Promise<void> { 19 + if (!this.plugin.settings.oauth.sessions) { 20 + this.plugin.settings.oauth.sessions = {}; 21 + } 22 + this.plugin.settings.oauth.sessions[key] = value; 23 + await this.plugin.saveSettings(); 24 + } 25 + 26 + async delete(key: Did): Promise<void> { 27 + if (this.plugin.settings.oauth.sessions) { 28 + delete this.plugin.settings.oauth.sessions[key]; 29 + await this.plugin.saveSettings(); 30 + return; 31 + } 32 + } 33 + 34 + async clear(): Promise<void> { 35 + this.plugin.settings.oauth.sessions = {}; 36 + await this.plugin.saveSettings(); 37 + } 38 + }
+20 -6
src/main.ts
··· 5 5 import { StandardFeedView, VIEW_ATMOSPHERE_STANDARD_FEED } from "views/standardfeed"; 6 6 import { ATClient } from "lib/client"; 7 7 import { Clipper } from "lib/clipper"; 8 + import { OAuthSessionStore } from "lib/oauth/oauthStore"; 8 9 9 10 export default class AtmospherePlugin extends Plugin { 10 11 settings: AtProtoSettings = DEFAULT_SETTINGS; ··· 13 14 14 15 async onload() { 15 16 await this.loadSettings(); 16 - 17 + this.client = new ATClient(new OAuthSessionStore(this)); 17 18 this.clipper = new Clipper(this); 18 19 19 20 this.registerView(VIEW_TYPE_ATMOSPHERE_BOOKMARKS, (leaf) => { ··· 66 67 } 67 68 68 69 69 - checkLoggedIn() { 70 - if (this.client?.loggedIn) { 70 + async checkAuth() { 71 + if (this.client.loggedIn) { 71 72 return true; 72 73 } 73 - 74 - new Notice("Please log in by opening Atmosphere settings"); 74 + if (this.settings.did) { 75 + try { 76 + await this.client.restoreSession(this.settings.did); 77 + return true 78 + } catch (e) { 79 + console.error("Failed to restore session:", e); 80 + // Clear invalid session data 81 + this.settings.did = undefined; 82 + this.settings.oauth.sessions = {}; 83 + await this.saveSettings(); 84 + new Notice("Session expired. Please login by opening settings"); 85 + return false; 86 + } 87 + } 88 + new Notice("Please log in by opening settings"); 75 89 return false; 76 90 } 77 91 78 92 async activateView(v: string) { 79 - if (!this.checkLoggedIn()) { 93 + if (!await this.checkAuth()) { 80 94 return; 81 95 } 82 96
+18 -25
src/settings.ts
··· 1 1 import { App, Notice, PluginSettingTab, Setting } from "obsidian"; 2 2 import type AtmospherePlugin from "./main"; 3 3 import { isActorIdentifier } from "@atcute/lexicons/syntax"; 4 - import { OauthServer } from "./lib/oauth"; 5 - import { ATClient } from "./lib/client"; 6 4 import { VIEW_TYPE_ATMOSPHERE_BOOKMARKS } from "./views/bookmarks"; 7 5 import { VIEW_ATMOSPHERE_STANDARD_FEED } from "./views/standardfeed"; 6 + import { StoredSession } from "@atcute/oauth-node-client"; 8 7 9 8 export interface AtProtoSettings { 10 - identifier: string; 9 + did?: string; 11 10 clipDir: string; 11 + oauth: { 12 + sessions?: Record<string, StoredSession> | null; 13 + } 12 14 publish: { 13 15 useFirstHeaderAsTitle: boolean; 14 16 }; 15 17 } 16 18 17 19 export const DEFAULT_SETTINGS: AtProtoSettings = { 18 - identifier: "", 19 20 clipDir: "AtmosphereClips", 21 + oauth: {}, 20 22 publish: { 21 23 useFirstHeaderAsTitle: false, 22 24 } ··· 34 36 const { containerEl } = this; 35 37 containerEl.empty(); 36 38 37 - containerEl.createEl("h2", { text: "Atmosphere Settings" }); 39 + if (this.plugin.settings.did) { 40 + const displayName = this.plugin.client.actor?.handle || this.plugin.settings.did; 38 41 39 - if (this.plugin.settings.identifier) { 40 42 new Setting(containerEl) 41 43 .setName("Logged in") 42 - .setDesc(this.plugin.settings.identifier); 44 + .setDesc(displayName); 43 45 44 46 new Setting(containerEl) 45 47 .setName("Log out") ··· 48 50 .setButtonText("Log out") 49 51 .setCta() 50 52 .onClick(async () => { 51 - this.plugin.client = null as any; 52 - 53 - this.plugin.settings.identifier = ""; 53 + await this.plugin.client.logout(this.plugin.settings.did!) 54 + this.plugin.settings.did = undefined; 54 55 await this.plugin.saveSettings(); 55 56 56 57 // close all plugin views ··· 66 67 67 68 new Setting(containerEl) 68 69 .setName("Log in") 69 - .setDesc("Enter your Bluesky or AT Protocol handle (e.g., user.bsky.social)") 70 + .setDesc("Enter your handle (e.g., user.bsky.social)") 70 71 .addText((text) => { 71 72 handleInput = text.inputEl; 72 - text.setPlaceholder("user.bsky.social") 73 - .setValue(""); 73 + text.setValue(""); 74 74 }) 75 75 .addButton((button) => 76 76 button ··· 85 85 } 86 86 87 87 if (!isActorIdentifier(handle)) { 88 - new Notice("Invalid handle format. Please enter a valid AT Protocol handle (e.g., user.bsky.social)."); 88 + new Notice("Invalid handle format. Please enter a valid handle (e.g., user.bsky.social)."); 89 89 return; 90 90 } 91 91 ··· 94 94 button.setButtonText("Logging in..."); 95 95 96 96 new Notice("Opening browser for authorization..."); 97 - 98 - const oauth = new OauthServer(); 99 - const session = await oauth.authorize(handle); 100 - 101 - this.plugin.client = new ATClient(session); 102 - this.plugin.settings.identifier = session.did; 103 - const actor = await this.plugin.client.getActor(session.did); 104 - 97 + await this.plugin.client.login(handle) 98 + this.plugin.settings.did = this.plugin.client.actor?.did; 105 99 await this.plugin.saveSettings(); 106 100 107 - new Notice(`Successfully logged in as ${actor.handle}`); 108 - 109 - this.display(); 101 + this.display() 102 + new Notice(`Successfully logged in as ${this.plugin.client.actor!.handle}`); 110 103 } catch (error) { 111 104 console.error("Login failed:", error); 112 105 const errorMessage = error instanceof Error ? error.message : String(error);
+4 -5
src/views/bookmarks.ts
··· 22 22 } 23 23 24 24 initSources() { 25 - if (this.plugin.settings.identifier) { 26 - const repo = this.plugin.settings.identifier; 25 + if (this.plugin.settings.did) { 26 + const repo = this.plugin.settings.did; 27 27 this.sources.set("semble", { 28 28 source: new SembleSource(this.plugin.client, repo), 29 29 filters: new Map() ··· 58 58 } 59 59 60 60 async fetchItems(): Promise<ATBookmarkItem[]> { 61 - if (!this.plugin.client) return []; 62 - 63 61 const sourceData = this.sources.get(this.activeSource); 64 62 if (!sourceData) return []; 65 63 ··· 72 70 container.empty(); 73 71 container.addClass("atmosphere-view"); 74 72 75 - if (!this.plugin.checkLoggedIn()) { 73 + 74 + if (!await this.plugin.checkAuth()) { 76 75 renderLoginMessage(container) 77 76 return 78 77 }
+2 -3
src/views/standardfeed.ts
··· 38 38 container.empty(); 39 39 container.addClass("standard-site-view"); 40 40 41 - // Check authentication 42 - if (!this.plugin.checkLoggedIn()) { 41 + if (!await this.plugin.checkAuth()) { 43 42 renderLoginMessage(container) 44 43 return 45 44 } ··· 50 49 const list = container.createEl("div", { cls: "standard-site-list" }); 51 50 52 51 try { 53 - const subsResp = await getSubscriptions(this.plugin.client, this.plugin.settings.identifier); 52 + const subsResp = await getSubscriptions(this.plugin.client, this.plugin.client.actor!.did); 54 53 if (subsResp.records.length === 0) { 55 54 loading.remove(); 56 55 container.createEl("p", { text: "No subscriptions found" });