This commit is contained in:
Jacobo de Vera 2026-04-19 00:00:24 +01:00 committed by GitHub
commit 1dd22a91ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 217 additions and 54 deletions

View file

@ -5,11 +5,21 @@ import { PluginSettingTabMock } from "./obsidian/PluginSettingTab";
import { NoticeMock } from "./obsidian/Notice";
import { setIconMock } from "./obsidian/setIcon";
class SecretComponentMock {
setValue(_value: string) {
return this;
}
onChange(_cb: (value: string) => unknown) {
return this;
}
}
module.exports = {
App: AppMock,
Plugin: PluginMock,
Modal: ModalMock,
PluginSettingTab: PluginSettingTabMock,
Notice: NoticeMock,
SecretComponent: SecretComponentMock,
setIcon: setIconMock,
};

View file

@ -1,4 +1,4 @@
import type { App, FileManager, Keymap, MetadataCache, Scope, UserEvent, Vault, Workspace } from "obsidian";
import type { App, FileManager, Keymap, MetadataCache, RenderContext, Scope, SecretStorage, UserEvent, Vault, Workspace } from "obsidian";
export class AppMock implements App {
get keymap(): Keymap {
@ -22,4 +22,21 @@ export class AppMock implements App {
get lastEvent(): UserEvent | null {
throw new Error("Not implemented.");
}
get renderContext(): RenderContext {
throw new Error("Not implemented.");
}
secretStorage: SecretStorage = {
setSecret: () => {},
getSecret: () => null,
listSecrets: () => [],
} as unknown as SecretStorage;
isDarkMode(): boolean {
return false;
}
loadLocalStorage(key: string): string | null {
return null;
}
saveLocalStorage(_key: string, _value: string | undefined): void {
throw new Error("Not implemented.");
}
}

View file

@ -30,4 +30,13 @@ export class ModalMock implements Modal {
onClose(): void {
throw new Error("Method not implemented.");
}
setTitle(title: string): this {
return this;
}
setContent(content: string | DocumentFragment): this {
return this;
}
setCloseCallback(cb: () => void): this {
return this;
}
}

View file

@ -4,6 +4,8 @@ import type { Notice } from "obsidian";
export class NoticeMock implements Notice {
constructor(message: string | DocumentFragment, duration?: number | undefined) {}
noticeEl!: HTMLElement;
containerEl!: HTMLElement;
messageEl!: HTMLElement;
setMessage(message: string | DocumentFragment): this {
throw new Error("Method not implemented.");
}

View file

@ -58,7 +58,16 @@ export class PluginMock implements Plugin {
registerCodeMirror(callback: (cm: Editor) => any): void {
throw new Error("Method not implemented.");
}
removeCommand(id: string): void {
throw new Error("Method not implemented.");
}
registerBasesView(id: string, factory: any): boolean {
throw new Error("Method not implemented.");
}
registerCliHandler(command: string, handler: any): void {
throw new Error("Method not implemented.");
}
onUserEnable(): void {}
registerObsidianProtocolHandler(action: string, handler: ObsidianProtocolHandler): void {
throw new Error("Method not implemented.");
}

View file

@ -1,6 +1,7 @@
import type { App, PluginSettingTab, Plugin } from "obsidian";
export class PluginSettingTabMock implements PluginSettingTab {
icon: string = "";
constructor(
public app: App,
public plugin: Plugin,

View file

@ -2,7 +2,7 @@
"id": "github-link",
"name": "GitHub Link",
"version": "1.2.0",
"minAppVersion": "0.15.0",
"minAppVersion": "1.11.4",
"description": "Enrich your notes with issue and pull request content from GitHub",
"author": "Nathonius",
"authorUrl": "https://nathan-smith.org/",

49
package-lock.json generated
View file

@ -33,7 +33,7 @@
"eslint-plugin-unused-imports": "^3.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"obsidian": "latest",
"obsidian": "^1.12.3",
"octokit": "^3.1.2",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
@ -673,18 +673,24 @@
}
},
"node_modules/@codemirror/state": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.0.tgz",
"integrity": "sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==",
"dev": true
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.23.0.tgz",
"integrity": "sha512-/51px9N4uW8NpuWkyUX+iam5+PM6io2fm+QmRnzwqBy5v/pwGg9T0kILFtYeum8hjuvENtgsGNKluOfqIICmeQ==",
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.4.0",
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
@ -1648,6 +1654,13 @@
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -3190,6 +3203,13 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -6398,17 +6418,18 @@
}
},
"node_modules/obsidian": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.4.11.tgz",
"integrity": "sha512-BCVYTvaXxElJMl6MMbDdY/CGK+aq18SdtDY/7vH8v6BxCBQ6KF4kKxL0vG9UZ0o5qh139KpUoJHNm+6O5dllKA==",
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
"integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/codemirror": "5.60.8",
"moment": "2.29.4"
},
"peerDependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.38.6"
}
},
"node_modules/obsidian/node_modules/moment": {

View file

@ -32,7 +32,7 @@
"eslint-plugin-unused-imports": "^3.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"obsidian": "latest",
"obsidian": "^1.12.3",
"octokit": "^3.1.2",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
@ -49,4 +49,4 @@
"lodash": "^4.17.21",
"queue": "^7.0.0"
}
}
}

66
src/keychain.ts Normal file
View file

@ -0,0 +1,66 @@
import type { SecretStorage } from "obsidian";
import type { GithubAccount } from "./settings/types";
import { logger } from "./plugin";
let storage: SecretStorage;
export function initKeychain(secretStorage: SecretStorage): void {
storage = secretStorage;
}
/**
* Read the token value from SecretStorage using the account's secret name.
*/
export function getToken(account: GithubAccount): string | null {
if (!account.tokenSecret) {
return null;
}
return storage.getSecret(account.tokenSecret);
}
/**
* Store a token under a named secret and update the account's tokenSecret reference.
*/
export function setToken(account: GithubAccount, token: string, secretName?: string): void {
if (!secretName && !account.tokenSecret) {
secretName = `github-link-${account.name.toLowerCase().replace(/[^a-z0-9]/g, "-")}`;
}
if (secretName) {
account.tokenSecret = secretName;
}
storage.setSecret(account.tokenSecret, token);
}
/**
* Clear the token value from SecretStorage (the entry remains with an empty value).
*/
export function clearToken(account: GithubAccount): void {
if (account.tokenSecret) {
storage.setSecret(account.tokenSecret, "");
}
}
/**
* Migrate plain-text tokens from data.json into named secrets.
* Called once on data version upgrade.
*/
export function migrateTokens(accounts: GithubAccount[]): void {
for (const account of accounts) {
if (account.token && !account.tokenSecret) {
setToken(account, account.token);
logger.info(`Migrated token for account "${account.name}" to Obsidian Keychain as "${account.tokenSecret}".`);
}
}
}
/**
* Populate in-memory account tokens from SecretStorage on startup.
*/
export function loadTokens(accounts: GithubAccount[]): void {
for (const account of accounts) {
const token = getToken(account);
if (token) {
account.token = token;
}
}
}

View file

@ -1,5 +1,5 @@
import { expect, jest, test, describe, beforeEach } from "@jest/globals";
import type { Plugin, RequestUrlResponse } from "obsidian";
import type { RequestUrlResponse } from "obsidian";
import { App } from "obsidian";
import type { PluginMock } from "../__mocks__/obsidian/Plugin";
import * as manifest from "../manifest.json";
@ -13,7 +13,7 @@ import { DATA_VERSION } from "./settings/types";
jest.mock("./settings/settings-tab");
function mockedPlugin(plugin: GithubLinkPlugin): PluginMock {
return plugin as Plugin as PluginMock;
return plugin as unknown as PluginMock;
}
describe("GithubLinkPlugin", () => {

View file

@ -10,11 +10,13 @@ import { createInlineViewPlugin } from "./inline/view-plugin";
import { RequestCache } from "./github/cache";
import { QueryProcessor } from "./query/processor";
import { DATA_VERSION } from "./settings/types";
import { initKeychain, loadTokens, migrateTokens } from "./keychain";
export const PluginSettings: GithubLinkPluginSettings = { ...DEFAULT_SETTINGS };
export const PluginData: GithubLinkPluginData = { cache: null, settings: PluginSettings, dataVersion: DATA_VERSION };
export const logger = new Logger();
let cache: RequestCache;
export function getCache(): RequestCache {
return cache;
}
@ -29,25 +31,30 @@ export class GithubLinkPlugin extends Plugin {
logger.logLevel = PluginSettings.logLevel;
cache = new RequestCache(PluginData.cache);
initKeychain(this.app.secretStorage);
if (data.dataVersion === undefined || PluginData.dataVersion < DATA_VERSION) {
// Always clear cache when data version changes
const entriesDeleted = cache.clean(new Date());
PluginData.cache = null;
PluginData.dataVersion = DATA_VERSION;
await this.saveData(PluginData);
migrateTokens(PluginSettings.accounts);
await this.saveData(this.getDataForSave());
new Notice(
`GitHub link data schema migrated to version ${DATA_VERSION}. Removed ${entriesDeleted} stored items from GitHub Link cache.`,
3000,
);
}
loadTokens(PluginSettings.accounts);
// Clean cache
const maxAge = new Date(new Date().getTime() - PluginSettings.maxCacheAgeHours * 60 * 60 * 1000);
const entriesDeleted = cache.clean(maxAge);
if (entriesDeleted > 0) {
PluginData.cache = cache.toJSON();
await this.saveData(PluginData);
await this.saveData(this.getDataForSave());
logger.info(`Cleaned ${entriesDeleted} entries from request cache.`);
}
@ -60,6 +67,20 @@ export class GithubLinkPlugin extends Plugin {
this.setCacheInterval();
}
/**
* Returns plugin data with tokens stripped from accounts, for safe persistence to data.json.
*/
public getDataForSave(): GithubLinkPluginData {
return {
cache: PluginData.cache,
settings: {
...PluginSettings,
accounts: PluginSettings.accounts.map((acc) => ({ ...acc, token: "" })),
},
dataVersion: DATA_VERSION,
};
}
/**
* Save cache at regular interval
*/
@ -68,7 +89,7 @@ export class GithubLinkPlugin extends Plugin {
logger.debug("Checking if cache needs a save.");
if (cache.cacheUpdated) {
PluginData.cache = cache.toJSON();
await this.saveData(PluginData);
await this.saveData(this.getDataForSave());
cache.cacheUpdated = false;
logger.info(`Saved request cache with ${PluginData.cache?.length} items.`);
}

View file

@ -1,8 +1,9 @@
import type { App } from "obsidian";
import { Setting } from "obsidian";
import { SecretComponent, Setting } from "obsidian";
import type { Verification } from "@octokit/auth-oauth-device/dist-types/types";
import { AuthModal } from "../auth-modal";
import { auth } from "../github/auth";
import { setToken } from "../keychain";
import type { GithubAccount } from "./types";
export class AccountSettings {
@ -28,7 +29,7 @@ export class AccountSettings {
saveNewAccountCallback: (account: GithubAccount) => Promise<void>,
): void {
if (!this.newAccount) {
this.newAccount = { id: crypto.randomUUID(), name: "", orgs: [], token: "", customOAuth: false };
this.newAccount = { id: crypto.randomUUID(), name: "", orgs: [], token: "", tokenSecret: "", customOAuth: false };
}
// TODO: Combine the new account and existing account rendering to reduce duplication
const accountContainer = container.createDiv();
@ -98,7 +99,7 @@ export class AccountSettings {
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.",
"A GitHub token stored in Obsidian's keychain. Generate one automatically (recommended) or link an existing secret.",
)
.addButton((button) => {
button.setButtonText("Generate Token");
@ -113,16 +114,19 @@ export class AccountSettings {
this.authModal?.close();
this.authModal = null;
this.newAccount!.token = authResult.token;
setToken(this.newAccount!, 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.trim();
});
});
.addComponent((el) =>
new SecretComponent(this.app, el)
.setValue(this.newAccount!.tokenSecret)
.onChange((secretName) => {
this.newAccount!.tokenSecret = secretName;
const token = this.app.secretStorage.getSecret(secretName);
this.newAccount!.token = token ?? "";
}),
);
new Setting(accountContainer).addButton((button) => {
button.setButtonText("Save account");
@ -204,7 +208,7 @@ export class AccountSettings {
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).",
"A GitHub token stored in Obsidian's keychain. Generate one automatically (recommended) or link an existing secret.",
)
.addButton((button) => {
button.setButtonText("Generate Token");
@ -219,18 +223,21 @@ export class AccountSettings {
this.authModal?.close();
this.authModal = null;
account.token = authResult.token;
setToken(account, 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.trim();
void this.saveCallback();
});
});
.addComponent((el) =>
new SecretComponent(this.app, el)
.setValue(account.tokenSecret)
.onChange(async (secretName) => {
account.tokenSecret = secretName;
const token = this.app.secretStorage.getSecret(secretName);
account.token = token ?? "";
await this.saveCallback();
}),
);
}
private tokenVerification(verification: Verification) {

View file

@ -5,8 +5,9 @@ import type { App } from "obsidian";
import type { GithubLinkPlugin } from "../plugin";
import { LogLevel } from "../logger";
import { PluginData, PluginSettings, getCache } from "../plugin";
import type { GithubAccount, GithubLinkPluginData } from "./types";
import { DATA_VERSION, DEFAULT_SETTINGS } from "./types";
import { clearToken } from "../keychain";
import type { GithubAccount } from "./types";
import { DEFAULT_SETTINGS } from "./types";
import { AccountSettings } from "./account";
export class GithubLinkPluginSettingsTab extends PluginSettingTab {
@ -30,7 +31,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
containerEl.createEl("h2", { text: "GitHub authentication" });
containerEl.createEl("p", {
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.",
text: "No authentication is required to reference public repositories. Providing a token allows referencing private repos. Tokens are stored securely in the Obsidian Keychain. You can create multiple accounts for multiple tokens.",
});
const newAccountSection = containerEl.createDiv();
@ -330,16 +331,12 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
}
private saveSettings() {
const newData: GithubLinkPluginData = {
cache: PluginData.cache,
settings: PluginSettings,
dataVersion: DATA_VERSION,
};
return this.plugin.saveData(newData);
return this.plugin.saveData(this.plugin.getDataForSave());
}
private async removeAccount(account: GithubAccount): Promise<void> {
PluginSettings.accounts.remove(account);
clearToken(account);
await this.saveSettings();
}

View file

@ -1,12 +1,15 @@
import { LogLevel } from "../logger";
export const DATA_VERSION = 1;
export const DATA_VERSION = 2;
export interface GithubAccount {
id: string;
name: string;
orgs: string[];
/** Runtime-only: populated from secretStorage on load. Not persisted. */
token: string;
/** Name of the secret in Obsidian's SecretStorage. Persisted in data.json. */
tokenSecret: string;
customOAuth?: boolean;
clientId?: string;
}