Various AT Protocol integrations with obsidian
20
fork

Configure Feed

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

initila work for redirect to obsidian uri scheme

+225 -148
+2 -1
.github/workflows/deploy_static.yml
··· 36 36 uses: actions/configure-pages@v5 37 37 - name: Prepare static files 38 38 run: | 39 - mkdir -p _site/home/treethought/Pictures/Screenshot_20260205_205803.png 39 + mkdir -p _site 40 40 cp client-metadata.json _site/ 41 + cp oauth-callback.html _site/ 41 42 - name: Upload artifact 42 43 uses: actions/upload-pages-artifact@v3 43 44 with:
+3 -3
client-metadata.json
··· 1 1 { 2 2 "client_id": "https://treethought.github.io/obsidian-atmosphere/client-metadata.json", 3 3 "client_name": "obsidian-atmosphere", 4 - "client_uri": "https://example.com", 4 + "client_uri": "https://treethought.github.io/obsidian-atmosphere", 5 5 "redirect_uris": [ 6 - "http://127.0.0.1/callback" 6 + "https://treethought.github.io/obsidian-atmosphere/oauth-callback.html" 7 7 ], 8 - "scope": "atproto transition:generic", 8 + "scope": "atproto include:at.margin.authFull repo:site.standard.document repo:network.cosmik.card repo:network.cosmik.collection repo:network.cosmik.collectionLink", 9 9 "grant_types": ["authorization_code", "refresh_token"], 10 10 "response_types": ["code"], 11 11 "token_endpoint_auth_method": "none",
+150
oauth-callback.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Atmosphere OAuth - Redirecting...</title> 7 + <style> 8 + * { 9 + margin: 0; 10 + padding: 0; 11 + box-sizing: border-box; 12 + } 13 + body { 14 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 15 + display: flex; 16 + align-items: center; 17 + justify-content: center; 18 + min-height: 100vh; 19 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 20 + padding: 1rem; 21 + } 22 + .container { 23 + text-align: center; 24 + padding: 3rem 2rem; 25 + background: white; 26 + border-radius: 16px; 27 + box-shadow: 0 20px 60px rgba(0,0,0,0.3); 28 + max-width: 500px; 29 + width: 100%; 30 + } 31 + h1 { 32 + color: #667eea; 33 + margin: 0 0 1rem 0; 34 + font-size: 1.75rem; 35 + font-weight: 600; 36 + } 37 + .spinner { 38 + margin: 2rem auto; 39 + width: 50px; 40 + height: 50px; 41 + border: 4px solid #f3f3f3; 42 + border-top: 4px solid #667eea; 43 + border-radius: 50%; 44 + animation: spin 1s linear infinite; 45 + } 46 + @keyframes spin { 47 + 0% { transform: rotate(0deg); } 48 + 100% { transform: rotate(360deg); } 49 + } 50 + p { 51 + color: #6b7280; 52 + margin: 1rem 0; 53 + line-height: 1.6; 54 + } 55 + .manual-link { 56 + margin-top: 2rem; 57 + padding: 1rem; 58 + background: #f9fafb; 59 + border-radius: 8px; 60 + border: 1px solid #e5e7eb; 61 + display: none; 62 + } 63 + .manual-link.show { 64 + display: block; 65 + } 66 + .link-text { 67 + word-break: break-all; 68 + font-family: monospace; 69 + font-size: 0.85rem; 70 + color: #374151; 71 + padding: 0.5rem; 72 + background: white; 73 + border-radius: 4px; 74 + margin-top: 0.5rem; 75 + } 76 + button { 77 + margin-top: 1rem; 78 + padding: 0.75rem 1.5rem; 79 + background: #667eea; 80 + color: white; 81 + border: none; 82 + border-radius: 8px; 83 + font-size: 1rem; 84 + font-weight: 500; 85 + cursor: pointer; 86 + transition: background 0.2s; 87 + } 88 + button:hover { 89 + background: #5568d3; 90 + } 91 + </style> 92 + </head> 93 + <body> 94 + <div class="container"> 95 + <h1>✅ Authentication Successful!</h1> 96 + <div class="spinner"></div> 97 + <p id="status">Redirecting to Obsidian...</p> 98 + <div class="manual-link" id="manual-link"> 99 + <p>If Obsidian doesn't open automatically:</p> 100 + <p style="font-size: 0.9rem; margin-bottom: 0.5rem;">1. Copy the link below</p> 101 + <div class="link-text" id="link-text"></div> 102 + <button onclick="copyLink()">Copy Link</button> 103 + <p style="font-size: 0.9rem; margin-top: 1rem;">2. Open Obsidian and paste it in your browser</p> 104 + </div> 105 + </div> 106 + 107 + <script> 108 + (function() { 109 + try { 110 + // Extract OAuth parameters from URL 111 + const params = new URLSearchParams(window.location.search); 112 + 113 + // Build Obsidian URI with all OAuth callback parameters 114 + const obsidianUri = `obsidian://atmosphere-oauth?${params.toString()}`; 115 + 116 + // Store the URI for manual copy 117 + document.getElementById('link-text').textContent = obsidianUri; 118 + 119 + // Attempt automatic redirect 120 + window.location.href = obsidianUri; 121 + 122 + // Show manual instructions after a delay if redirect didn't work 123 + setTimeout(function() { 124 + document.getElementById('status').textContent = 'Having trouble redirecting?'; 125 + document.getElementById('manual-link').classList.add('show'); 126 + }, 2000); 127 + 128 + } catch (error) { 129 + console.error('Redirect error:', error); 130 + document.getElementById('status').textContent = 'An error occurred during redirect'; 131 + document.getElementById('manual-link').classList.add('show'); 132 + } 133 + })(); 134 + 135 + function copyLink() { 136 + const linkText = document.getElementById('link-text').textContent; 137 + navigator.clipboard.writeText(linkText).then(function() { 138 + const btn = event.target; 139 + btn.textContent = '✓ Copied!'; 140 + setTimeout(function() { 141 + btn.textContent = 'Copy Link'; 142 + }, 2000); 143 + }).catch(function(err) { 144 + console.error('Failed to copy:', err); 145 + alert('Failed to copy. Please select and copy the link manually.'); 146 + }); 147 + } 148 + </script> 149 + </body> 150 + </html>
+8
src/lib/client.ts
··· 38 38 async getActor(identifier: string): Promise<ResolvedActor> { 39 39 return this.hh.getActor(identifier); 40 40 } 41 + 42 + handleOAuthCallback(params: URLSearchParams): void { 43 + this.hh.handleOAuthCallback(params); 44 + } 41 45 } 42 46 43 47 /** ··· 65 69 async logout(identifier: string): Promise<void> { 66 70 await this.oauth.revoke(identifier); 67 71 this.session = undefined; 72 + } 73 + 74 + handleOAuthCallback(params: URLSearchParams): void { 75 + this.oauth.handleCallback(params); 68 76 } 69 77 70 78 async getActor(identifier: string): Promise<ResolvedActor> {
+45 -144
src/lib/oauth/oauth.ts
··· 1 - import * as http from 'http'; 2 - import { OAuthClient, MemoryStore, type StoredState, type OAuthSession, } from '@atcute/oauth-node-client'; 1 + import { OAuthClient, MemoryStore, type StoredState, type OAuthSession } from '@atcute/oauth-node-client'; 3 2 import { compositeResolver } from 'lib/identity'; 4 3 import { Notice } from 'obsidian'; 5 4 import { OAuthSessionStore } from './oauthStore'; 6 - import { isDid } from "@atcute/lexicons/syntax"; 5 + import { ActorIdentifier, isDid } from "@atcute/lexicons/syntax"; 6 + import metadata from '../../../client-metadata.json' with { type: 'json' }; 7 7 8 8 const TEN_MINUTES_MS = 10 * 60_000; 9 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 10 export class OAuthHandler { 139 11 private oauth: OAuthClient 140 - private server: OAuthServer; 141 12 private sessionStore: OAuthSessionStore; 13 + private callbackResolver: ((value: URLSearchParams) => void) | null = null; 14 + private callbackRejecter: ((reason?: Error) => void) | null = null; 15 + private callbackTimeout: ReturnType<typeof setTimeout> | null = null; 142 16 143 17 constructor(sessionStore: OAuthSessionStore) { 144 - this.server = new OAuthServer(); 145 18 this.sessionStore = sessionStore; 146 - // Initialize OAuth client immediately so restore() works 147 - this.initClient('http://127.0.0.1/callback'); 19 + // Initialize OAuth client with hosted redirect URL 20 + this.initClient(metadata.redirect_uris[0] || ""); 148 21 } 149 22 150 23 initClient(redirectUri: string): void { 151 24 this.oauth = new OAuthClient({ 152 25 metadata: { 153 - redirect_uris: [redirectUri], // updated after starting server 26 + client_id: metadata.client_id, 27 + redirect_uris: [redirectUri], 154 28 scope: 'atproto include:at.margin.authFull repo:site.standard.document repo:network.cosmik.card repo:network.cosmik.collection repo:network.cosmik.collectionLink', 155 29 }, 156 30 actorResolver: compositeResolver, ··· 165 39 }); 166 40 } 167 41 42 + handleCallback(params: URLSearchParams): void { 43 + if (this.callbackResolver) { 44 + if (this.callbackTimeout) { 45 + clearTimeout(this.callbackTimeout); 46 + this.callbackTimeout = null; 47 + } 48 + this.callbackResolver(params); 49 + this.callbackResolver = null; 50 + this.callbackRejecter = null; 51 + } 52 + } 168 53 169 54 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); 55 + const redirectUri = metadata.redirect_uris[0]!; 56 + 57 + // Reinitialize client with current redirect URI 58 + this.initClient(redirectUri); 59 + 60 + const { url } = await this.oauth.authorize({ 61 + target: { type: 'account', identifier: identifier as ActorIdentifier }, 62 + redirectUri: redirectUri, 63 + }); 173 64 174 - const { url } = await this.oauth!.authorize({ 175 - target: { type: 'account', identifier: identifier as any }, 176 - redirectUri: result.redirectUri, 65 + // Create promise for callback 66 + const waitForCallback = new Promise<URLSearchParams>((resolve, reject) => { 67 + this.callbackResolver = resolve; 68 + this.callbackRejecter = reject; 69 + 70 + // Timeout after 5 minutes 71 + this.callbackTimeout = setTimeout(() => { 72 + if (this.callbackRejecter) { 73 + this.callbackRejecter(new Error('OAuth callback timed out after 5 minutes')); 74 + this.callbackResolver = null; 75 + this.callbackRejecter = null; 76 + } 77 + }, 5 * 60_000); 177 78 }); 178 79 179 80 window.open(url.href, '_blank'); 180 81 new Notice('Continue login in the browser') 181 82 182 - const params = await result.waitForCallback; 83 + const params = await waitForCallback; 84 + const { session } = await this.oauth.callback(params, { redirectUri }); 183 85 184 - const { session } = await this.oauth!.callback(params, { redirectUri: result.redirectUri }); 185 86 return session; 186 87 } 187 88
+17
src/main.ts
··· 17 17 this.client = new ATClient(new OAuthSessionStore(this)); 18 18 this.clipper = new Clipper(this); 19 19 20 + this.registerObsidianProtocolHandler('atmosphere-oauth', (params) => { 21 + try { 22 + const urlParams = new URLSearchParams(); 23 + for (const [key, value] of Object.entries(params)) { 24 + if (value) { 25 + urlParams.set(key, String(value)); 26 + } 27 + } 28 + 29 + this.client.handleOAuthCallback(urlParams); 30 + new Notice('Authentication completed! Processing...'); 31 + } catch (error) { 32 + console.error('Error handling OAuth callback:', error); 33 + new Notice('Authentication error. Please try again.'); 34 + } 35 + }); 36 + 20 37 this.registerView(VIEW_TYPE_ATMOSPHERE_BOOKMARKS, (leaf) => { 21 38 return new AtmosphereView(leaf, this); 22 39 });