Merge pull request #99 from nathonius/feat/84/oauth

feat/84/oauth
This commit is contained in:
Nathan 2024-04-11 22:41:02 -04:00 committed by GitHub
commit 240189c961
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 349 additions and 197 deletions

View file

@ -4,6 +4,8 @@ import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
import { request } from "@octokit/request";
import { requestUrl } from "obsidian";
const defaultClientId = "baf0370cb98e1387d244";
function getHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
if (!headers) {
return undefined;
@ -71,10 +73,10 @@ async function doFetch(url: RequestInfo | URL, options?: RequestInit | undefined
return partialResult as Response;
}
export const auth = (verificationHandler: OnVerificationCallback) =>
export const auth = (verificationHandler: OnVerificationCallback, clientId: string = defaultClientId) =>
createOAuthDeviceAuth({
clientType: "oauth-app",
clientId: "baf0370cb98e1387d244",
clientId,
scopes: ["repo"],
onVerification: verificationHandler,
request: request.defaults({

View file

@ -1,7 +1,7 @@
import { DEFAULT_SETTINGS, GithubLinkPluginSettingsTab } from "./settings";
import { Logger } from "./logger";
import type { GithubLinkPluginSettings } from "./settings";
import type { GithubLinkPluginData, GithubLinkPluginSettings } from "./settings";
import { InlineRenderer } from "./inline/inline";
import { Plugin } from "obsidian";
import { QueryProcessor } from "./query/processor";
@ -9,6 +9,7 @@ import { createInlineViewPlugin } from "./inline/view-plugin";
import { RequestCache } from "./github/cache";
export const PluginSettings: GithubLinkPluginSettings = { ...DEFAULT_SETTINGS };
export const PluginData: GithubLinkPluginData = { cache: null, settings: PluginSettings };
export const logger = new Logger();
let cache: RequestCache;
export function getCache(): RequestCache {
@ -18,16 +19,40 @@ export function getCache(): RequestCache {
export class GithubLinkPlugin extends Plugin {
public cacheInterval: number | undefined;
async onload() {
Object.assign(PluginSettings, await this.loadData());
let data = await this.loadData();
// Migrate settings from data root to settings -- remove in v1.0.0
if (data.accounts) {
const newSettings: GithubLinkPluginSettings = {
accounts: data.accounts ?? PluginSettings.accounts,
cacheIntervalSeconds: data.cacheIntervalSeconds ?? PluginSettings.cacheIntervalSeconds,
defaultPageSize: data.defaultPageSize ?? PluginSettings.defaultPageSize,
logLevel: data.logLevel ?? PluginSettings.logLevel,
maxCacheAgeHours: data.maxCacheAgeHours ?? PluginSettings.maxCacheAgeHours,
minRequestSeconds: data.minRequestSeconds ?? PluginSettings.minRequestSeconds,
tagShowPRMergeable: data.tagShowPRMergeable ?? PluginSettings.tagShowPRMergeable,
tagTooltips: data.tagTooltips ?? PluginSettings.tagTooltips,
defaultAccount: data.defaultAccount ?? PluginSettings.defaultAccount,
};
const newData: GithubLinkPluginData = {
cache: data.cache ?? PluginData.cache,
settings: newSettings,
};
await this.saveData(newData);
data = newData;
}
Object.assign(PluginSettings, data.settings);
Object.assign(PluginData, data);
logger.logLevel = PluginSettings.logLevel;
cache = new RequestCache(PluginSettings.cache);
cache = new RequestCache(PluginData.cache);
// Clean cache
const maxAge = new Date(new Date().getTime() - PluginSettings.maxCacheAgeHours * 60 * 60 * 1000);
const entriesDeleted = cache.clean(maxAge);
if (entriesDeleted > 0) {
PluginSettings.cache = cache.toJSON();
PluginData.cache = cache.toJSON();
await this.saveData(PluginSettings);
logger.info(`Cleaned ${entriesDeleted} entries from request cache.`);
}
@ -50,10 +75,10 @@ export class GithubLinkPlugin extends Plugin {
window.setInterval(async () => {
logger.debug("Checking if cache needs a save.");
if (cache.cacheUpdated) {
PluginSettings.cache = cache.toJSON();
await this.saveData(PluginSettings);
PluginData.cache = cache.toJSON();
await this.saveData(PluginData);
cache.cacheUpdated = false;
logger.info(`Saved request cache with ${PluginSettings.cache?.length} items.`);
logger.info(`Saved request cache with ${PluginData.cache?.length} items.`);
}
}, PluginSettings.cacheIntervalSeconds * 1000),
);

236
src/settings/account.ts Normal file
View file

@ -0,0 +1,236 @@
import type { App } from "obsidian";
import { Setting } from "obsidian";
import type { GithubAccount } from "./types";
import { AuthModal } from "src/auth-modal";
import { auth } from "src/github/auth";
import type { Verification } from "@octokit/auth-oauth-device/dist-types/types";
export class AccountSettings {
authModal: AuthModal | null = null;
newAccount: GithubAccount | null = null;
constructor(
private readonly app: App,
private readonly container: HTMLElement,
private readonly saveCallback: () => Promise<void>,
private readonly displayCallback: () => void,
private readonly removeCallback: (account: GithubAccount) => void,
) {}
public render(accounts: GithubAccount[]): void {
for (const account of accounts) {
this.renderAccountSetting(account);
}
}
public renderNewAccount(
container: HTMLElement,
saveNewAccountCallback: (account: GithubAccount) => Promise<void>,
): void {
if (!this.newAccount) {
this.newAccount = { id: crypto.randomUUID(), name: "", orgs: [], token: "", customOAuth: false };
}
// TODO: Combine the new account and existing account rendering to reduce duplication
const accountContainer = container.createDiv();
const header = accountContainer.createEl("h3", { text: "New account" });
new Setting(accountContainer)
.setName("Account name")
.setDesc("Required.")
.addText((text) => {
text.setValue(this.newAccount!.name);
text.onChange((value) => {
this.newAccount!.name = value;
header.setText(value ?? "New account");
});
})
.addButton((button) => {
button.setIcon("trash");
button.setTooltip("Delete account");
button.onClick(() => {
this.newAccount = null;
this.displayCallback();
});
});
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc(
"A comma separated list of the GitHub organizations and users this account should be used for. Optional.",
)
.addTextArea((text) => {
text.setValue(this.newAccount!.orgs.join(", "));
text.onChange((value) => {
this.newAccount!.orgs = value.split(",").map((acc) => acc.trim());
});
});
const oauthDesc = createFragment((fragment) => {
fragment.appendText(
"You can optionally provide your own OAuth app for more control and a larger request rate limit. See the ",
);
fragment.createEl("a", {
text: "plugin documentation",
href: "https://github.com/nathonius/obsidian-github-link/wiki/Authentication#custom-oauth-app",
});
fragment.appendText(" for instructions.");
});
const customOAuthSetting = new Setting(accountContainer)
.setName("Use custom OAuth app")
.setDesc(oauthDesc)
.addToggle((toggle) => {
toggle.setValue(this.newAccount!.customOAuth ?? false);
toggle.onChange((value) => {
this.newAccount!.customOAuth = value;
this.displayCallback();
});
});
if (this.newAccount.customOAuth) {
customOAuthSetting.addText((text) => {
text.setValue(this.newAccount!.clientId ?? "");
text.setPlaceholder("Client ID");
text.onChange((value) => {
this.newAccount!.clientId = value;
});
});
}
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens). Required.",
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(async () => {
const authResult = await auth(this.tokenVerification.bind(this))({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
this.newAccount!.token = authResult.token;
this.displayCallback();
});
})
.addText((text) => {
text.setPlaceholder("Personal Access Token / OAuth Token");
text.setValue(this.newAccount!.token);
text.onChange((value) => {
this.newAccount!.token = value;
});
});
new Setting(accountContainer).addButton((button) => {
button.setButtonText("Save account");
button.setTooltip("Save account");
button.setIcon("save");
button.onClick(async () => {
if (!this.newAccount?.name) {
return;
}
await saveNewAccountCallback(this.newAccount);
this.newAccount = null;
this.displayCallback();
});
});
}
public renderAccountSetting(account: GithubAccount, parent: HTMLElement = this.container): void {
const accountContainer = parent.createDiv();
accountContainer.createEl("h3", { text: account.name });
new Setting(accountContainer)
.setName("Account name")
.addText((text) => {
text.setValue(account.name);
text.onChange((value) => {
account.name = value;
this.saveCallback();
});
})
.addButton((button) => {
button.setIcon("trash");
button.setTooltip("Delete account");
button.onClick(async () => {
this.removeCallback(account);
await this.saveCallback();
this.displayCallback();
});
});
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc("A comma separated list of the GitHub organizations and users this account should be used for.")
.addTextArea((text) => {
text.setValue(account.orgs.join(", "));
text.onChange((value) => {
account.orgs = value.split(",").map((org) => org.trim());
this.saveCallback();
});
});
const oauthDesc = createFragment((fragment) => {
fragment.appendText(
"You can optionally provide your own OAuth app for more control and a larger request rate limit. See the ",
);
fragment.createEl("a", { text: "plugin documentation", href: "#" });
fragment.appendText(" for instructions.");
});
const customOAuthSetting = new Setting(accountContainer)
.setName("Use custom OAuth app")
.setDesc(oauthDesc)
.addToggle((toggle) => {
toggle.setValue(account.customOAuth ?? false);
toggle.onChange((value) => {
account.customOAuth = value;
this.displayCallback();
});
});
if (account.customOAuth) {
customOAuthSetting.addText((text) => {
text.setValue(account.clientId ?? "");
text.setPlaceholder("Client ID");
text.onChange((value) => {
account.clientId = value;
this.saveCallback();
});
});
}
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens).",
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(async () => {
const customClientId = account.customOAuth ? account.clientId : undefined;
const authResult = await auth(
this.tokenVerification.bind(this),
customClientId,
)({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
account.token = authResult.token;
await this.saveCallback();
this.displayCallback();
});
})
.addText((text) => {
text.setPlaceholder("Personal Access Token / OAuth Token");
text.setValue(account.token);
text.onChange((value) => {
account.token = value;
this.saveCallback();
});
});
}
private tokenVerification(verification: Verification) {
this.authModal = new AuthModal(this.app, verification);
this.authModal.open();
}
}

3
src/settings/index.ts Normal file
View file

@ -0,0 +1,3 @@
export type { GithubAccount, GithubLinkPluginData, GithubLinkPluginSettings } from "./types";
export { DEFAULT_SETTINGS } from "./types";
export { GithubLinkPluginSettingsTab } from "./settings-tab";

View file

@ -2,49 +2,21 @@
import { Notice, PluginSettingTab, Setting } from "obsidian";
import type { App } from "obsidian";
import { AuthModal } from "./auth-modal";
import type { GithubLinkPlugin } from "./plugin";
import { LogLevel } from "./logger";
import { PluginSettings, getCache } from "./plugin";
import type { Verification } from "@octokit/auth-oauth-device/dist-types/types";
import { auth } from "./github/auth";
export interface GithubAccount {
id: string;
name: string;
orgs: string[];
token: string;
}
export interface GithubLinkPluginSettings {
accounts: GithubAccount[];
defaultAccount?: string;
defaultPageSize: number;
logLevel: LogLevel;
tagTooltips: boolean;
tagShowPRMergeable: boolean;
cacheIntervalSeconds: number;
maxCacheAgeHours: number;
minRequestSeconds: number;
cache: string[] | null; // TODO: Move this out of settings; it's not a setting it's just part of plugin data.
}
export const DEFAULT_SETTINGS: GithubLinkPluginSettings = {
accounts: [],
defaultPageSize: 10,
logLevel: LogLevel.Error,
tagTooltips: false,
tagShowPRMergeable: false,
cacheIntervalSeconds: 60,
maxCacheAgeHours: 120,
minRequestSeconds: 60,
cache: null,
};
import type { GithubLinkPlugin } from "../plugin";
import { LogLevel } from "../logger";
import { PluginData, PluginSettings, getCache } from "../plugin";
import type { GithubAccount, GithubLinkPluginData } from "./types";
import { DEFAULT_SETTINGS } from "./types";
import { AccountSettings } from "./account";
export class GithubLinkPluginSettingsTab extends PluginSettingTab {
authModal: AuthModal | null = null;
newAccount: GithubAccount | null = null;
private readonly accountSettings = new AccountSettings(
this.app,
this.containerEl,
this.saveSettings.bind(this),
this.display.bind(this),
this.removeAccount.bind(this),
);
constructor(
public app: App,
private readonly plugin: GithubLinkPlugin,
@ -52,7 +24,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
super(app, plugin);
}
display() {
public display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "GitHub authentication" });
@ -61,16 +33,20 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
text: "No authentication is required to reference public repositories. Providing a token allows referencing private repos, but the token is stored in plain text. You can create multiple accounts for multiple tokens.",
});
new Setting(containerEl).setName("Add account").addButton((button) => {
const newAccountSection = containerEl.createDiv();
new Setting(newAccountSection).setName("Add account").addButton((button) => {
button.setButtonText("");
button.setIcon("plus");
button.setTooltip("Add Account");
button.onClick(() => {
this.newAccount = { id: crypto.randomUUID(), name: "", orgs: [], token: "" };
this.display();
this.accountSettings.renderNewAccount(newAccountSection, this.saveNewAccount.bind(this));
});
});
if (this.accountSettings.newAccount) {
this.accountSettings.renderNewAccount(newAccountSection, this.saveNewAccount.bind(this));
}
new Setting(containerEl)
.setName("Default account")
.setDesc("The account that will be used if no other users or organizations match.")
@ -90,144 +66,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
});
});
// TODO: Combine the new account and existing account rendering to reduce duplication
if (this.newAccount !== null) {
const accountContainer = containerEl.createDiv();
const header = accountContainer.createEl("h3", { text: "New account" });
new Setting(accountContainer)
.setName("Account name")
.setDesc("Required.")
.addText((text) => {
text.setValue(this.newAccount!.name);
text.onChange((value) => {
this.newAccount!.name = value;
header.setText(value ?? "New account");
});
})
.addButton((button) => {
button.setIcon("trash");
button.setTooltip("Delete account");
button.onClick(() => {
this.newAccount = null;
this.display();
});
});
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc(
"A comma separated list of the GitHub organizations and users this account should be used for. Optional.",
)
.addTextArea((text) => {
text.setValue(this.newAccount!.orgs.join(", "));
text.onChange((value) => {
this.newAccount!.orgs = value.split(",");
});
});
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens). Required.",
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(async () => {
const authResult = await auth(this.tokenVerification.bind(this))({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
this.newAccount!.token = authResult.token;
this.display();
});
})
.addText((text) => {
text.setPlaceholder("Personal Access Token / OAuth Token");
text.setValue(this.newAccount!.token);
text.onChange((value) => {
this.newAccount!.token = value;
});
});
new Setting(accountContainer).addButton((button) => {
button.setButtonText("Save account");
button.setTooltip("Save account");
button.setIcon("save");
button.onClick(async () => {
if (!this.newAccount?.name || !this.newAccount.token) {
return;
}
PluginSettings.accounts.unshift(this.newAccount);
await this.saveSettings();
this.newAccount = null;
this.display();
});
});
}
for (const account of PluginSettings.accounts) {
const accountContainer = containerEl.createDiv();
accountContainer.createEl("h3", { text: account.name });
new Setting(accountContainer)
.setName("Account name")
.addText((text) => {
text.setValue(account.name);
text.onChange((value) => {
account.name = value;
this.saveSettings();
});
})
.addButton((button) => {
button.setIcon("trash");
button.setTooltip("Delete account");
button.onClick(async () => {
PluginSettings.accounts.remove(account);
await this.saveSettings();
this.display();
});
});
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc("A comma separated list of the GitHub organizations and users this account should be used for.")
.addTextArea((text) => {
text.setValue(account.orgs.join(", "));
text.onChange((value) => {
account.orgs = value.split(",").map((org) => org.trim());
this.saveSettings();
});
});
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens).",
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(async () => {
const authResult = await auth(this.tokenVerification.bind(this))({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
account.token = authResult.token;
await this.saveSettings();
this.display();
});
})
.addText((text) => {
text.setPlaceholder("Personal Access Token / OAuth Token");
text.setValue(account.token);
text.onChange((value) => {
account.token = value;
this.saveSettings();
});
});
}
this.accountSettings.render(PluginSettings.accounts);
containerEl.createEl("h2", { text: "Other settings" });
@ -374,7 +213,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
button.setButtonText("Clear cache");
button.onClick(async () => {
const itemsDeleted = getCache().clean(new Date());
PluginSettings.cache = null;
PluginData.cache = null;
await this.saveSettings();
new Notice(`Removed ${itemsDeleted} stored items from GitHub Link cache.`, 3000);
});
@ -408,11 +247,20 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
}
private saveSettings() {
return this.plugin.saveData(PluginSettings);
const newData: GithubLinkPluginData = {
cache: PluginData.cache,
settings: PluginSettings,
};
return this.plugin.saveData(newData);
}
private tokenVerification(verification: Verification) {
this.authModal = new AuthModal(this.app, verification);
this.authModal.open();
private async removeAccount(account: GithubAccount): Promise<void> {
PluginSettings.accounts.remove(account);
await this.saveSettings();
}
private async saveNewAccount(account: GithubAccount): Promise<void> {
PluginSettings.accounts.unshift(account);
await this.saveSettings();
}
}

38
src/settings/types.ts Normal file
View file

@ -0,0 +1,38 @@
import { LogLevel } from "src/logger";
export interface GithubAccount {
id: string;
name: string;
orgs: string[];
token: string;
customOAuth?: boolean;
clientId?: string;
}
export interface GithubLinkPluginData {
settings: GithubLinkPluginSettings;
cache: string[] | null;
}
export interface GithubLinkPluginSettings {
accounts: GithubAccount[];
defaultAccount?: string;
defaultPageSize: number;
logLevel: LogLevel;
tagTooltips: boolean;
tagShowPRMergeable: boolean;
cacheIntervalSeconds: number;
maxCacheAgeHours: number;
minRequestSeconds: number;
}
export const DEFAULT_SETTINGS: GithubLinkPluginSettings = {
accounts: [],
defaultPageSize: 10,
logLevel: LogLevel.Error,
tagTooltips: false,
tagShowPRMergeable: false,
cacheIntervalSeconds: 60,
maxCacheAgeHours: 120,
minRequestSeconds: 60,
};