mirror of
https://github.com/svemagie/obsidian-micropub.git
synced 2026-05-15 03:48:52 +02:00
feat: IndieAuth PKCE sign-in + GitHub Pages relay callback
- Add IndieAuth.ts: full PKCE sign-in flow via GitHub Pages relay - Add docs/index.html: client_id page fetched by IndieKit for app info - Add docs/callback/index.html: relay that forwards to obsidian:// URI - Update SettingsTab.ts: signed-in/signed-out UI, Sign In button - Update types.ts: authorizationEndpoint, tokenEndpoint, me fields - Update main.ts: register obsidian://micropub-auth protocol handler
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Returning to Obsidian…</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; display: flex; align-items: center;
|
||||||
|
justify-content: center; min-height: 100vh; margin: 0; background: #f9fafb; }
|
||||||
|
.card { text-align: center; padding: 2rem; border-radius: 12px; background: #fff;
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,.08); max-width: 360px; }
|
||||||
|
.icon { font-size: 3rem; margin-bottom: .5rem; }
|
||||||
|
h1 { font-size: 1.3rem; margin: .5rem 0; }
|
||||||
|
p { color: #6b7280; margin: .5rem 0; font-size: .9rem; }
|
||||||
|
.error { color: #dc2626; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card" id="card">
|
||||||
|
<div class="icon">⏳</div>
|
||||||
|
<h1>Returning to Obsidian…</h1>
|
||||||
|
<p>Opening the Micropub Publisher plugin.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get("code");
|
||||||
|
const state = params.get("state");
|
||||||
|
const error = params.get("error");
|
||||||
|
const errorDesc = params.get("error_description");
|
||||||
|
|
||||||
|
const card = document.getElementById("card");
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
card.innerHTML =
|
||||||
|
'<div class="icon">❌</div>' +
|
||||||
|
'<h1 class="error">Authorization failed</h1>' +
|
||||||
|
'<p>' + (errorDesc || error) + '</p>' +
|
||||||
|
'<p>You can close this tab and try again in Obsidian.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
card.innerHTML =
|
||||||
|
'<div class="icon">❌</div>' +
|
||||||
|
'<h1 class="error">Missing parameters</h1>' +
|
||||||
|
'<p>No authorization code was received.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to the Obsidian protocol handler.
|
||||||
|
// obsidian://micropub-auth is registered by the plugin via
|
||||||
|
// this.registerObsidianProtocolHandler("micropub-auth", handler)
|
||||||
|
const obsidianUrl =
|
||||||
|
"obsidian://micropub-auth?code=" +
|
||||||
|
encodeURIComponent(code) +
|
||||||
|
"&state=" +
|
||||||
|
encodeURIComponent(state);
|
||||||
|
|
||||||
|
window.location.href = obsidianUrl;
|
||||||
|
|
||||||
|
// Fallback message if Obsidian doesn't open
|
||||||
|
setTimeout(function () {
|
||||||
|
card.innerHTML =
|
||||||
|
'<div class="icon">✅</div>' +
|
||||||
|
'<h1>Authorized!</h1>' +
|
||||||
|
'<p>If Obsidian did not open automatically, click below.</p>' +
|
||||||
|
'<p><a href="' + obsidianUrl + '" style="color:#6c3fc7">Open in Obsidian →</a></p>' +
|
||||||
|
'<p>You can close this tab afterwards.</p>';
|
||||||
|
}, 2000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Micropub Publisher for Obsidian</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 60px auto; padding: 0 20px; color: #1a1a1a; }
|
||||||
|
h1 { font-size: 1.5rem; }
|
||||||
|
a { color: #6c3fc7; }
|
||||||
|
code { background: #f3f0ff; padding: 2px 6px; border-radius: 4px; font-size: .9em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Micropub Publisher for Obsidian</h1>
|
||||||
|
<p>
|
||||||
|
An <a href="https://obsidian.md">Obsidian</a> plugin to publish notes to any
|
||||||
|
<a href="https://micropub.spec.indieweb.org/">Micropub</a>-compatible endpoint —
|
||||||
|
<a href="https://getindiekit.com">Indiekit</a>, Micro.blog, or any IndieWeb server.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/svemagie/obsidian-micropub">View on GitHub →</a>
|
||||||
|
</p>
|
||||||
|
<hr>
|
||||||
|
<p style="font-size:.85rem;color:#666">
|
||||||
|
This page serves as the OAuth <code>client_id</code> for the IndieAuth sign-in flow.
|
||||||
|
After authorizing, you will be redirected back to Obsidian automatically.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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<string, string>) => 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<string, string>): 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 <link rel="..."> tags in the HTML <head>.
|
||||||
|
*/
|
||||||
|
static async discoverEndpoints(siteUrl: string): Promise<DiscoveredEndpoints> {
|
||||||
|
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 <link rel="authorization_endpoint"> found at ${siteUrl}. ` +
|
||||||
|
"Make sure Indiekit is running and SITE_URL is set correctly.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!tokenEndpoint) {
|
||||||
|
throw new Error(`No <link rel="token_endpoint"> 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<IndieAuthResult> {
|
||||||
|
// 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<Record<string, string>>(
|
||||||
|
(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(
|
||||||
|
`<link[^>]+rel=["'][^"']*\\b${rel}\\b[^"']*["'][^>]+href=["']([^"']+)["']` +
|
||||||
|
`|<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*\\b${rel}\\b[^"']*["']`,
|
||||||
|
"i",
|
||||||
|
);
|
||||||
|
const m = html.match(re);
|
||||||
|
return m?.[1] ?? m?.[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
+212
-110
@@ -1,10 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* SettingsTab.ts — Obsidian settings UI for obsidian-micropub
|
* 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 <me>" + a Sign Out button
|
||||||
|
*
|
||||||
|
* Advanced users can still paste a token manually if they prefer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
|
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
|
||||||
import type MicropubPlugin from "./main";
|
import type MicropubPlugin from "./main";
|
||||||
import { MicropubClient } from "./MicropubClient";
|
import { MicropubClient } from "./MicropubClient";
|
||||||
|
import { IndieAuth } from "./IndieAuth";
|
||||||
|
|
||||||
export class MicropubSettingsTab extends PluginSettingTab {
|
export class MicropubSettingsTab extends PluginSettingTab {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -20,66 +30,27 @@ export class MicropubSettingsTab extends PluginSettingTab {
|
|||||||
|
|
||||||
containerEl.createEl("h2", { text: "Micropub Publisher" });
|
containerEl.createEl("h2", { text: "Micropub Publisher" });
|
||||||
|
|
||||||
// ── Endpoint discovery ───────────────────────────────────────────────
|
// ── Site URL + Sign In ───────────────────────────────────────────────
|
||||||
containerEl.createEl("h3", { text: "Endpoint Configuration" });
|
containerEl.createEl("h3", { text: "Account" });
|
||||||
|
|
||||||
new Setting(containerEl)
|
// Show current sign-in status
|
||||||
.setName("Site URL")
|
if (this.plugin.settings.me && this.plugin.settings.accessToken) {
|
||||||
.setDesc(
|
this.renderSignedIn(containerEl);
|
||||||
"Your site's home page. Used to auto-discover Micropub and token endpoints " +
|
} else {
|
||||||
"from <link rel=\"micropub\"> headers.",
|
this.renderSignedOut(containerEl);
|
||||||
)
|
|
||||||
.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…");
|
// ── Endpoints (collapsed / advanced) ────────────────────────────────
|
||||||
try {
|
containerEl.createEl("h3", { text: "Endpoints" });
|
||||||
const client = new MicropubClient(
|
|
||||||
() => this.plugin.settings.micropubEndpoint,
|
containerEl.createEl("p", {
|
||||||
() => this.plugin.settings.mediaEndpoint,
|
text: "These are filled automatically when you sign in. Only edit them manually if your server uses non-standard paths.",
|
||||||
() => this.plugin.settings.accessToken,
|
cls: "setting-item-description",
|
||||||
);
|
});
|
||||||
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");
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Micropub endpoint")
|
.setName("Micropub endpoint")
|
||||||
.setDesc("e.g. https://example.com/micropub")
|
.setDesc("e.g. https://blog.giersig.eu/micropub")
|
||||||
.addText((text) =>
|
.addText((text) =>
|
||||||
text
|
text
|
||||||
.setPlaceholder("https://example.com/micropub")
|
.setPlaceholder("https://example.com/micropub")
|
||||||
@@ -92,9 +63,7 @@ export class MicropubSettingsTab extends PluginSettingTab {
|
|||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Media endpoint")
|
.setName("Media endpoint")
|
||||||
.setDesc(
|
.setDesc("For image uploads. Auto-discovered if blank.")
|
||||||
"Leave blank to discover automatically from the Micropub config response.",
|
|
||||||
)
|
|
||||||
.addText((text) =>
|
.addText((text) =>
|
||||||
text
|
text
|
||||||
.setPlaceholder("https://example.com/micropub/media")
|
.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 ────────────────────────────────────────────────
|
// ── Publish behaviour ────────────────────────────────────────────────
|
||||||
containerEl.createEl("h3", { text: "Publish Behaviour" });
|
containerEl.createEl("h3", { text: "Publish Behaviour" });
|
||||||
|
|
||||||
@@ -175,8 +98,8 @@ export class MicropubSettingsTab extends PluginSettingTab {
|
|||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Write URL back to note")
|
.setName("Write URL back to note")
|
||||||
.setDesc(
|
.setDesc(
|
||||||
"After publishing, store the returned post URL as `mp-url` in the note's " +
|
"After publishing, store the post URL as `mp-url` in frontmatter. " +
|
||||||
"frontmatter. Subsequent publishes will use this URL to update the post.",
|
"Subsequent publishes will update the existing post instead of creating a new one.",
|
||||||
)
|
)
|
||||||
.addToggle((toggle) =>
|
.addToggle((toggle) =>
|
||||||
toggle
|
toggle
|
||||||
@@ -193,9 +116,8 @@ export class MicropubSettingsTab extends PluginSettingTab {
|
|||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Map #garden/* tags to gardenStage")
|
.setName("Map #garden/* tags to gardenStage")
|
||||||
.setDesc(
|
.setDesc(
|
||||||
"When enabled, Obsidian tags like #garden/plant are converted to a " +
|
"Obsidian tags like #garden/plant become a `garden-stage: plant` Micropub " +
|
||||||
"`garden-stage: plant` Micropub property. The Eleventy blog theme renders " +
|
"property. The blog renders these as growth stage badges at /garden/.",
|
||||||
"these as growth stage badges and groups posts in the /garden/ index.",
|
|
||||||
)
|
)
|
||||||
.addToggle((toggle) =>
|
.addToggle((toggle) =>
|
||||||
toggle
|
toggle
|
||||||
@@ -207,8 +129,188 @@ export class MicropubSettingsTab extends PluginSettingTab {
|
|||||||
);
|
);
|
||||||
|
|
||||||
containerEl.createEl("p", {
|
containerEl.createEl("p", {
|
||||||
text: "Supported stages: plant 🌱 · cultivate 🌿 · question ❓ · repot 🪴 · revitalize ✨ · revisit 🔄",
|
text: "Stages: plant 🌱 · cultivate 🌿 · question ❓ · repot 🪴 · revitalize ✨ · revisit 🔄",
|
||||||
cls: "setting-item-description",
|
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();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Notice, Plugin, TFile } from "obsidian";
|
|||||||
import { DEFAULT_SETTINGS, type MicropubSettings } from "./types";
|
import { DEFAULT_SETTINGS, type MicropubSettings } from "./types";
|
||||||
import { MicropubSettingsTab } from "./SettingsTab";
|
import { MicropubSettingsTab } from "./SettingsTab";
|
||||||
import { Publisher } from "./Publisher";
|
import { Publisher } from "./Publisher";
|
||||||
|
import { handleProtocolCallback } from "./IndieAuth";
|
||||||
|
|
||||||
export default class MicropubPlugin extends Plugin {
|
export default class MicropubPlugin extends Plugin {
|
||||||
settings!: MicropubSettings;
|
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<string, string>);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Settings tab ─────────────────────────────────────────────────────
|
// ── Settings tab ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
this.addSettingTab(new MicropubSettingsTab(this.app, this));
|
this.addSettingTab(new MicropubSettingsTab(this.app, this));
|
||||||
|
|||||||
+22
-1
@@ -32,9 +32,27 @@ export interface MicropubSettings {
|
|||||||
*/
|
*/
|
||||||
autoDiscover: boolean;
|
autoDiscover: boolean;
|
||||||
|
|
||||||
/** Your site's homepage URL — used for endpoint discovery. */
|
/** Your site's homepage URL — used for endpoint discovery and IndieAuth. */
|
||||||
siteUrl: string;
|
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
|
* When true, after a successful publish the post URL returned by the server
|
||||||
* is written back to the note's frontmatter as `mp-url`.
|
* is written back to the note's frontmatter as `mp-url`.
|
||||||
@@ -59,6 +77,9 @@ export const DEFAULT_SETTINGS: MicropubSettings = {
|
|||||||
defaultSyndicateTo: [],
|
defaultSyndicateTo: [],
|
||||||
autoDiscover: false,
|
autoDiscover: false,
|
||||||
siteUrl: "",
|
siteUrl: "",
|
||||||
|
authorizationEndpoint: "",
|
||||||
|
tokenEndpoint: "",
|
||||||
|
me: "",
|
||||||
writeUrlToFrontmatter: true,
|
writeUrlToFrontmatter: true,
|
||||||
mapGardenTags: true,
|
mapGardenTags: true,
|
||||||
defaultVisibility: "public",
|
defaultVisibility: "public",
|
||||||
|
|||||||
Reference in New Issue
Block a user