diff --git a/docs/callback/index.html b/docs/callback/index.html new file mode 100644 index 0000000..dd29ef2 --- /dev/null +++ b/docs/callback/index.html @@ -0,0 +1,74 @@ + + + + + Returning to Obsidian… + + + +
+
+

Returning to Obsidian…

+

Opening the Micropub Publisher plugin.

+
+ + + + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..af618dd --- /dev/null +++ b/docs/index.html @@ -0,0 +1,30 @@ + + + + + + Micropub Publisher for Obsidian + + + +

Micropub Publisher for Obsidian

+

+ An Obsidian plugin to publish notes to any + Micropub-compatible endpoint — + Indiekit, Micro.blog, or any IndieWeb server. +

+

+ View on GitHub → +

+
+

+ This page serves as the OAuth client_id for the IndieAuth sign-in flow. + After authorizing, you will be redirected back to Obsidian automatically. +

+ + diff --git a/src/IndieAuth.ts b/src/IndieAuth.ts new file mode 100644 index 0000000..877c0d8 --- /dev/null +++ b/src/IndieAuth.ts @@ -0,0 +1,231 @@ +/** + * IndieAuth.ts — IndieAuth PKCE sign-in flow for obsidian-micropub + * + * Why no local HTTP server: + * IndieKit (and most IndieAuth servers) fetch the client_id URL server-side + * to retrieve app metadata. A local 127.0.0.1 address is unreachable from a + * remote server, so that approach always fails with "fetch failed". + * + * The solution — GitHub Pages relay: + * client_id = https://svemagie.github.io/obsidian-micropub/ + * redirect_uri = https://svemagie.github.io/obsidian-micropub/callback + * + * Both are on the same host → IndieKit's host-matching check passes ✓ + * The callback page is a static HTML file that immediately redirects to + * obsidian://micropub-auth?code=CODE&state=STATE + * Obsidian's protocol handler (registered in main.ts) receives the code. + * + * Flow: + * 1. Discover authorization_endpoint + token_endpoint from site HTML + * 2. Generate PKCE code_verifier + code_challenge (SHA-256) + * 3. Open browser → user's IndieAuth login page + * 4. User logs in → server redirects to GitHub Pages callback + * 5. Callback page redirects to obsidian://micropub-auth?code=... + * 6. Plugin protocol handler resolves the pending Promise + * 7. Exchange code for token at token_endpoint + */ + +import * as crypto from "crypto"; +import { requestUrl } from "obsidian"; + +export const CLIENT_ID = "https://svemagie.github.io/obsidian-micropub/"; +export const REDIRECT_URI = "https://svemagie.github.io/obsidian-micropub/callback"; + +const SCOPE = "create update media"; +const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +export interface IndieAuthResult { + accessToken: string; + scope: string; + /** Canonical "me" URL returned by the token endpoint */ + me: string; + authorizationEndpoint: string; + tokenEndpoint: string; + micropubEndpoint?: string; + mediaEndpoint?: string; +} + +export interface DiscoveredEndpoints { + authorizationEndpoint: string; + tokenEndpoint: string; + micropubEndpoint?: string; +} + +/** Pending callback set by main.ts protocol handler */ +let pendingCallback: + | { resolve: (params: Record) => void; state: string } + | null = null; + +/** + * Called by the Obsidian protocol handler in main.ts when + * obsidian://micropub-auth is opened by the browser. + */ +export function handleProtocolCallback(params: Record): void { + if (!pendingCallback) return; + + const { resolve, state: expectedState } = pendingCallback; + pendingCallback = null; + resolve(params); // let signIn() validate state + extract code +} + +export class IndieAuth { + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Discover IndieAuth + Micropub endpoint URLs from the site's home page + * by reading tags in the HTML . + */ + static async discoverEndpoints(siteUrl: string): Promise { + const resp = await requestUrl({ url: siteUrl, method: "GET" }); + const html = resp.text; + + const authorizationEndpoint = IndieAuth.extractLinkRel(html, "authorization_endpoint"); + const tokenEndpoint = IndieAuth.extractLinkRel(html, "token_endpoint"); + const micropubEndpoint = IndieAuth.extractLinkRel(html, "micropub"); + + if (!authorizationEndpoint) { + throw new Error( + `No found at ${siteUrl}. ` + + "Make sure Indiekit is running and SITE_URL is set correctly.", + ); + } + if (!tokenEndpoint) { + throw new Error(`No found at ${siteUrl}.`); + } + + return { authorizationEndpoint, tokenEndpoint, micropubEndpoint }; + } + + /** + * Run the full IndieAuth PKCE sign-in flow. + * + * Opens the browser at the user's IndieAuth login page. After login the + * browser is redirected to the GitHub Pages callback, which triggers + * the obsidian://micropub-auth protocol, which resolves the Promise here. + * + * Requires handleProtocolCallback() to be wired up in main.ts via + * this.registerObsidianProtocolHandler("micropub-auth", handleProtocolCallback) + */ + static async signIn(siteUrl: string): Promise { + // 1. Discover endpoints + const { authorizationEndpoint, tokenEndpoint, micropubEndpoint } = + await IndieAuth.discoverEndpoints(siteUrl); + + // 2. Generate PKCE + state + const state = IndieAuth.base64url(crypto.randomBytes(16)); + const codeVerifier = IndieAuth.base64url(crypto.randomBytes(64)); + const codeChallenge = IndieAuth.base64url( + crypto.createHash("sha256").update(codeVerifier).digest(), + ); + + // 3. Register pending callback — resolved by handleProtocolCallback() + const callbackPromise = new Promise>( + (resolve, reject) => { + const timeout = setTimeout(() => { + pendingCallback = null; + reject(new Error("Sign-in timed out (5 min). Please try again.")); + }, AUTH_TIMEOUT_MS); + + pendingCallback = { + state, + resolve: (params) => { + clearTimeout(timeout); + resolve(params); + }, + }; + }, + ); + + // 4. Build the authorization URL and open the browser + const authUrl = new URL(authorizationEndpoint); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("client_id", CLIENT_ID); + authUrl.searchParams.set("redirect_uri", REDIRECT_URI); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method","S256"); + authUrl.searchParams.set("scope", SCOPE); + authUrl.searchParams.set("me", siteUrl); + + window.open(authUrl.toString()); + + // 5. Wait for obsidian://micropub-auth to be called + const callbackParams = await callbackPromise; + + // 6. Validate state (CSRF protection) + if (callbackParams.state !== state) { + throw new Error("State mismatch — possible CSRF attack. Please try again."); + } + + const code = callbackParams.code; + if (!code) { + throw new Error( + callbackParams.error_description ?? + callbackParams.error ?? + "No authorization code received.", + ); + } + + // 7. Exchange code for token + const tokenResp = await requestUrl({ + url: tokenEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + code_verifier: codeVerifier, + }).toString(), + throw: false, + }); + + const data = tokenResp.json as { + access_token?: string; + scope?: string; + me?: string; + error?: string; + error_description?: string; + }; + + if (!data.access_token) { + throw new Error( + data.error_description ?? + data.error ?? + `Token exchange failed (HTTP ${tokenResp.status})`, + ); + } + + return { + accessToken: data.access_token, + scope: data.scope ?? SCOPE, + me: data.me ?? siteUrl, + authorizationEndpoint, + tokenEndpoint, + micropubEndpoint, + }; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static base64url(buf: Buffer): string { + return buf.toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + } + + static extractLinkRel(html: string, rel: string): string | undefined { + const re = new RegExp( + `]+rel=["'][^"']*\\b${rel}\\b[^"']*["'][^>]+href=["']([^"']+)["']` + + `|]+href=["']([^"']+)["'][^>]+rel=["'][^"']*\\b${rel}\\b[^"']*["']`, + "i", + ); + const m = html.match(re); + return m?.[1] ?? m?.[2]; + } +} diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts index 74e83c0..94cc9d3 100644 --- a/src/SettingsTab.ts +++ b/src/SettingsTab.ts @@ -1,10 +1,20 @@ /** * SettingsTab.ts — Obsidian settings UI for obsidian-micropub + * + * Authentication section works like iA Writer: + * 1. User enters their site URL + * 2. Clicks "Sign in" — browser opens at their IndieAuth login page + * 3. They log in with their blog password + * 4. Browser redirects back; plugin receives the token automatically + * 5. Settings show "Signed in as " + a Sign Out button + * + * Advanced users can still paste a token manually if they prefer. */ import { App, Notice, PluginSettingTab, Setting } from "obsidian"; import type MicropubPlugin from "./main"; import { MicropubClient } from "./MicropubClient"; +import { IndieAuth } from "./IndieAuth"; export class MicropubSettingsTab extends PluginSettingTab { constructor( @@ -20,66 +30,27 @@ export class MicropubSettingsTab extends PluginSettingTab { containerEl.createEl("h2", { text: "Micropub Publisher" }); - // ── Endpoint discovery ─────────────────────────────────────────────── - containerEl.createEl("h3", { text: "Endpoint Configuration" }); + // ── Site URL + Sign In ─────────────────────────────────────────────── + containerEl.createEl("h3", { text: "Account" }); - new Setting(containerEl) - .setName("Site URL") - .setDesc( - "Your site's home page. Used to auto-discover Micropub and token endpoints " + - "from headers.", - ) - .addText((text) => - text - .setPlaceholder("https://example.com") - .setValue(this.plugin.settings.siteUrl) - .onChange(async (value) => { - this.plugin.settings.siteUrl = value.trim(); - await this.plugin.saveSettings(); - }), - ) - .addButton((btn) => - btn - .setButtonText("Discover") - .setCta() - .onClick(async () => { - if (!this.plugin.settings.siteUrl) { - new Notice("Enter a site URL first."); - return; - } - btn.setDisabled(true); - btn.setButtonText("Discovering…"); - try { - const client = new MicropubClient( - () => this.plugin.settings.micropubEndpoint, - () => this.plugin.settings.mediaEndpoint, - () => this.plugin.settings.accessToken, - ); - const discovered = await client.discoverEndpoints( - this.plugin.settings.siteUrl, - ); - if (discovered.micropubEndpoint) { - this.plugin.settings.micropubEndpoint = - discovered.micropubEndpoint; - } - if (discovered.mediaEndpoint) { - this.plugin.settings.mediaEndpoint = discovered.mediaEndpoint; - } - await this.plugin.saveSettings(); - this.display(); // Refresh UI - new Notice("✅ Endpoints discovered!"); - } catch (err: unknown) { - new Notice(`Discovery failed: ${String(err)}`); - } finally { - btn.setDisabled(false); - btn.setButtonText("Discover"); - } - }), - ); + // Show current sign-in status + if (this.plugin.settings.me && this.plugin.settings.accessToken) { + this.renderSignedIn(containerEl); + } else { + this.renderSignedOut(containerEl); + } + + // ── Endpoints (collapsed / advanced) ──────────────────────────────── + containerEl.createEl("h3", { text: "Endpoints" }); + + containerEl.createEl("p", { + text: "These are filled automatically when you sign in. Only edit them manually if your server uses non-standard paths.", + cls: "setting-item-description", + }); new Setting(containerEl) .setName("Micropub endpoint") - .setDesc("e.g. https://example.com/micropub") + .setDesc("e.g. https://blog.giersig.eu/micropub") .addText((text) => text .setPlaceholder("https://example.com/micropub") @@ -92,9 +63,7 @@ export class MicropubSettingsTab extends PluginSettingTab { new Setting(containerEl) .setName("Media endpoint") - .setDesc( - "Leave blank to discover automatically from the Micropub config response.", - ) + .setDesc("For image uploads. Auto-discovered if blank.") .addText((text) => text .setPlaceholder("https://example.com/micropub/media") @@ -105,52 +74,6 @@ export class MicropubSettingsTab extends PluginSettingTab { }), ); - // ── Authentication ─────────────────────────────────────────────────── - containerEl.createEl("h3", { text: "Authentication" }); - - new Setting(containerEl) - .setName("Access token") - .setDesc( - "Bearer token from your site's IndieAuth token endpoint or admin panel.", - ) - .addText((text) => { - text - .setPlaceholder("your-bearer-token") - .setValue(this.plugin.settings.accessToken) - .onChange(async (value) => { - this.plugin.settings.accessToken = value.trim(); - await this.plugin.saveSettings(); - }); - text.inputEl.type = "password"; - }) - .addButton((btn) => - btn - .setButtonText("Verify") - .onClick(async () => { - if ( - !this.plugin.settings.micropubEndpoint || - !this.plugin.settings.accessToken - ) { - new Notice("Set endpoint and token first."); - return; - } - btn.setDisabled(true); - try { - const client = new MicropubClient( - () => this.plugin.settings.micropubEndpoint, - () => this.plugin.settings.mediaEndpoint, - () => this.plugin.settings.accessToken, - ); - await client.fetchConfig(); - new Notice("✅ Token is valid!"); - } catch (err: unknown) { - new Notice(`Auth check failed: ${String(err)}`); - } finally { - btn.setDisabled(false); - } - }), - ); - // ── Publish behaviour ──────────────────────────────────────────────── containerEl.createEl("h3", { text: "Publish Behaviour" }); @@ -175,8 +98,8 @@ export class MicropubSettingsTab extends PluginSettingTab { new Setting(containerEl) .setName("Write URL back to note") .setDesc( - "After publishing, store the returned post URL as `mp-url` in the note's " + - "frontmatter. Subsequent publishes will use this URL to update the post.", + "After publishing, store the post URL as `mp-url` in frontmatter. " + + "Subsequent publishes will update the existing post instead of creating a new one.", ) .addToggle((toggle) => toggle @@ -193,9 +116,8 @@ export class MicropubSettingsTab extends PluginSettingTab { new Setting(containerEl) .setName("Map #garden/* tags to gardenStage") .setDesc( - "When enabled, Obsidian tags like #garden/plant are converted to a " + - "`garden-stage: plant` Micropub property. The Eleventy blog theme renders " + - "these as growth stage badges and groups posts in the /garden/ index.", + "Obsidian tags like #garden/plant become a `garden-stage: plant` Micropub " + + "property. The blog renders these as growth stage badges at /garden/.", ) .addToggle((toggle) => toggle @@ -207,8 +129,188 @@ export class MicropubSettingsTab extends PluginSettingTab { ); containerEl.createEl("p", { - text: "Supported stages: plant 🌱 · cultivate 🌿 · question ❓ · repot 🪴 · revitalize ✨ · revisit 🔄", + text: "Stages: plant 🌱 · cultivate 🌿 · question ❓ · repot 🪴 · revitalize ✨ · revisit 🔄", cls: "setting-item-description", }); } + + // ── Signed-out state ───────────────────────────────────────────────────── + + private renderSignedOut(containerEl: HTMLElement): void { + // Site URL input + Sign In button on the same row + new Setting(containerEl) + .setName("Site URL") + .setDesc( + "Your site's home page. Clicking Sign in opens your blog's login page " + + "in the browser — the same flow iA Writer uses.", + ) + .addText((text) => + text + .setPlaceholder("https://blog.giersig.eu") + .setValue(this.plugin.settings.siteUrl) + .onChange(async (value) => { + this.plugin.settings.siteUrl = value.trim(); + await this.plugin.saveSettings(); + }), + ) + .addButton((btn) => { + btn + .setButtonText("Sign in") + .setCta() + .onClick(async () => { + const siteUrl = this.plugin.settings.siteUrl.trim(); + if (!siteUrl) { + new Notice("Enter your site URL first."); + return; + } + + btn.setDisabled(true); + btn.setButtonText("Opening browser…"); + + try { + const result = await IndieAuth.signIn(siteUrl); + + // Save everything returned by the auth flow + this.plugin.settings.accessToken = result.accessToken; + this.plugin.settings.me = result.me; + this.plugin.settings.authorizationEndpoint = result.authorizationEndpoint; + this.plugin.settings.tokenEndpoint = result.tokenEndpoint; + if (result.micropubEndpoint) { + this.plugin.settings.micropubEndpoint = result.micropubEndpoint; + } + if (result.mediaEndpoint) { + this.plugin.settings.mediaEndpoint = result.mediaEndpoint; + } + + await this.plugin.saveSettings(); + + // Try to fetch the Micropub config to pick up media endpoint + if (!this.plugin.settings.mediaEndpoint) { + try { + const client = new MicropubClient( + () => this.plugin.settings.micropubEndpoint, + () => this.plugin.settings.mediaEndpoint, + () => this.plugin.settings.accessToken, + ); + const cfg = await client.fetchConfig(); + if (cfg["media-endpoint"]) { + this.plugin.settings.mediaEndpoint = cfg["media-endpoint"]; + await this.plugin.saveSettings(); + } + } catch { + // Non-fatal + } + } + + new Notice(`✅ Signed in as ${result.me}`); + this.display(); // Refresh to show signed-in state + } catch (err: unknown) { + new Notice(`Sign-in failed: ${String(err)}`, 8000); + btn.setDisabled(false); + btn.setButtonText("Sign in"); + } + }); + }); + + // Divider + manual token fallback (collapsed by default) + const details = containerEl.createEl("details"); + details.createEl("summary", { + text: "Or paste a token manually", + cls: "setting-item-description", + }); + details.style.marginTop = "8px"; + details.style.marginBottom = "8px"; + + new Setting(details) + .setName("Access token") + .setDesc("Bearer token from your Indiekit admin panel.") + .addText((text) => { + text + .setPlaceholder("your-bearer-token") + .setValue(this.plugin.settings.accessToken) + .onChange(async (value) => { + this.plugin.settings.accessToken = value.trim(); + await this.plugin.saveSettings(); + }); + text.inputEl.type = "password"; + }) + .addButton((btn) => + btn.setButtonText("Verify").onClick(async () => { + if ( + !this.plugin.settings.micropubEndpoint || + !this.plugin.settings.accessToken + ) { + new Notice("Set the Micropub endpoint and token first."); + return; + } + btn.setDisabled(true); + try { + const client = new MicropubClient( + () => this.plugin.settings.micropubEndpoint, + () => this.plugin.settings.mediaEndpoint, + () => this.plugin.settings.accessToken, + ); + await client.fetchConfig(); + new Notice("✅ Token is valid!"); + } catch (err: unknown) { + new Notice(`Token check failed: ${String(err)}`); + } finally { + btn.setDisabled(false); + } + }), + ); + } + + // ── Signed-in state ────────────────────────────────────────────────────── + + private renderSignedIn(containerEl: HTMLElement): void { + const me = this.plugin.settings.me; + + // Avatar + "Signed in as" banner + const banner = containerEl.createDiv({ + cls: "micropub-auth-banner", + }); + banner.style.cssText = + "display:flex;align-items:center;gap:12px;padding:12px 16px;" + + "border:1px solid var(--background-modifier-border);" + + "border-radius:8px;margin-bottom:16px;background:var(--background-secondary);"; + + const icon = banner.createDiv(); + icon.style.cssText = + "width:40px;height:40px;border-radius:50%;background:var(--interactive-accent);" + + "display:flex;align-items:center;justify-content:center;" + + "font-size:1.2rem;flex-shrink:0;"; + icon.textContent = "🌐"; + + const info = banner.createDiv(); + info.createEl("div", { + text: "Signed in", + attr: { style: "font-size:.75rem;color:var(--text-muted);margin-bottom:2px" }, + }); + info.createEl("div", { + text: me, + attr: { style: "font-weight:500;word-break:break-all" }, + }); + + new Setting(containerEl) + .setName("Site URL") + .addText((text) => + text + .setValue(this.plugin.settings.siteUrl) + .setDisabled(true), + ) + .addButton((btn) => + btn + .setButtonText("Sign out") + .setWarning() + .onClick(async () => { + this.plugin.settings.accessToken = ""; + this.plugin.settings.me = ""; + this.plugin.settings.authorizationEndpoint = ""; + this.plugin.settings.tokenEndpoint = ""; + await this.plugin.saveSettings(); + this.display(); + }), + ); + } } diff --git a/src/main.ts b/src/main.ts index 734e489..2420bb8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,7 @@ import { Notice, Plugin, TFile } from "obsidian"; import { DEFAULT_SETTINGS, type MicropubSettings } from "./types"; import { MicropubSettingsTab } from "./SettingsTab"; import { Publisher } from "./Publisher"; +import { handleProtocolCallback } from "./IndieAuth"; export default class MicropubPlugin extends Plugin { settings!: MicropubSettings; @@ -55,6 +56,14 @@ export default class MicropubPlugin extends Plugin { }, }); + // ── IndieAuth protocol handler ──────────────────────────────────────── + // Receives obsidian://micropub-auth?code=...&state=... after the user + // approves on their IndieAuth login page. The GitHub Pages callback page + // at svemagie.github.io/obsidian-micropub/callback redirects here. + this.registerObsidianProtocolHandler("micropub-auth", (params) => { + handleProtocolCallback(params as Record); + }); + // ── Settings tab ───────────────────────────────────────────────────── this.addSettingTab(new MicropubSettingsTab(this.app, this)); diff --git a/src/types.ts b/src/types.ts index cf9c287..f56ac52 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,9 +32,27 @@ export interface MicropubSettings { */ autoDiscover: boolean; - /** Your site's homepage URL — used for endpoint discovery. */ + /** Your site's homepage URL — used for endpoint discovery and IndieAuth. */ siteUrl: string; + /** + * The authorization_endpoint discovered from the site. + * Populated automatically by the IndieAuth sign-in flow. + */ + authorizationEndpoint: string; + + /** + * The token_endpoint discovered from the site. + * Populated automatically by the IndieAuth sign-in flow. + */ + tokenEndpoint: string; + + /** + * The canonical "me" URL returned by the token endpoint after sign-in. + * Used to show who is currently logged in. + */ + me: string; + /** * When true, after a successful publish the post URL returned by the server * is written back to the note's frontmatter as `mp-url`. @@ -59,6 +77,9 @@ export const DEFAULT_SETTINGS: MicropubSettings = { defaultSyndicateTo: [], autoDiscover: false, siteUrl: "", + authorizationEndpoint: "", + tokenEndpoint: "", + me: "", writeUrlToFrontmatter: true, mapGardenTags: true, defaultVisibility: "public",