diff --git a/package-lock.json b/package-lock.json index f946984..52d5e10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.20", "license": "MIT", "dependencies": { + "node-forge": "^1.3.1", "yaml": "^2.7.0" }, "devDependencies": { @@ -1852,6 +1853,15 @@ "license": "MIT", "peer": true }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/obsidian": { "version": "1.5.7-1", "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.5.7-1.tgz", diff --git a/package.json b/package.json index 7883323..5154a48 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "typescript": "4.7.4" }, "dependencies": { + "node-forge": "^1.3.1", "yaml": "^2.7.0" } } diff --git a/src/api.ts b/src/api.ts index 918a84b..7ba360e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -38,11 +38,16 @@ export class DiscourseAPI { formDataArray.set(endBoundaryArray, imgFormArray.length + bodyArray.length + imgfile.byteLength); const url = `${this.settings.baseUrl}/uploads.json`; - const headers = { - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - "Content-Type": `multipart/form-data; boundary=${boundary}`, - }; + const headers: Record = this.settings.userApiKey + ? { + "User-Api-Key": this.settings.userApiKey, + "Content-Type": `multipart/form-data; boundary=${boundary}` + } + : { + "Api-Key": this.settings.apiKey || '', + "Api-Username": this.settings.disUser || '', + "Content-Type": `multipart/form-data; boundary=${boundary}` + }; const response = await requestUrl({ url: url, @@ -68,11 +73,16 @@ export class DiscourseAPI { // 创建新帖子 async createPost(title: string, content: string, category: number, tags: string[]): Promise<{ success: boolean; postId?: number; topicId?: number; error?: string }> { const url = `${this.settings.baseUrl}/posts.json`; - const headers = { - "Content-Type": "application/json", - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; + const headers: Record = this.settings.userApiKey + ? { + "User-Api-Key": this.settings.userApiKey, + "Content-Type": "application/json" + } + : { + "Content-Type": "application/json", + "Api-Key": this.settings.apiKey || '', + "Api-Username": this.settings.disUser || '' + }; try { const response = await requestUrl({ @@ -141,11 +151,16 @@ export class DiscourseAPI { async updatePost(postId: number, topicId: number, title: string, content: string, category: number, tags: string[]): Promise<{ success: boolean; error?: string }> { const postEndpoint = `${this.settings.baseUrl}/posts/${postId}`; const topicEndpoint = `${this.settings.baseUrl}/t/${topicId}`; - const headers = { - "Content-Type": "application/json", - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; + const headers: Record = this.settings.userApiKey + ? { + "User-Api-Key": this.settings.userApiKey, + "Content-Type": "application/json" + } + : { + "Content-Type": "application/json", + "Api-Key": this.settings.apiKey || '', + "Api-Username": this.settings.disUser || '' + }; try { // 首先更新帖子内容 @@ -222,10 +237,9 @@ export class DiscourseAPI { async fetchCategories(): Promise<{ id: number; name: string }[]> { try { const url = `${this.settings.baseUrl}/categories.json?include_subcategories=true`; - const headers = { - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; + const headers: Record = this.settings.userApiKey + ? { "User-Api-Key": this.settings.userApiKey } + : { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' }; const response = await requestUrl({ url, @@ -272,10 +286,9 @@ export class DiscourseAPI { async fetchTags(): Promise<{ name: string; canCreate: boolean }[]> { try { const url = `${this.settings.baseUrl}/tags.json`; - const headers = { - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; + const headers: Record = this.settings.userApiKey + ? { "User-Api-Key": this.settings.userApiKey } + : { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' }; const response = await requestUrl({ url, @@ -313,10 +326,9 @@ export class DiscourseAPI { async checkCanCreateTags(): Promise { try { const url = `${this.settings.baseUrl}/site.json`; - const headers = { - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; + const headers: Record = this.settings.userApiKey + ? { "User-Api-Key": this.settings.userApiKey } + : { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' }; const response = await requestUrl({ url, @@ -340,7 +352,7 @@ export class DiscourseAPI { // 测试API密钥 async testApiKey(): Promise<{ success: boolean; message: string }> { - if (!this.settings.baseUrl || !this.settings.apiKey || !this.settings.disUser) { + if (!this.settings.baseUrl || (!this.settings.apiKey && !this.settings.userApiKey) || !this.settings.disUser) { return { success: false, message: t('MISSING_SETTINGS') @@ -349,10 +361,9 @@ export class DiscourseAPI { try { const url = `${this.settings.baseUrl}/users/${this.settings.disUser}.json`; - const headers = { - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; + const headers: Record = this.settings.userApiKey + ? { "User-Api-Key": this.settings.userApiKey } + : { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' }; const response = await requestUrl({ url, @@ -392,10 +403,9 @@ export class DiscourseAPI { async fetchTopicInfo(topicId: number): Promise<{ tags: string[], categoryId?: number }> { try { const url = `${this.settings.baseUrl}/t/${topicId}.json`; - const headers = { - "Api-Key": this.settings.apiKey, - "Api-Username": this.settings.disUser, - }; + const headers: Record = this.settings.userApiKey + ? { "User-Api-Key": this.settings.userApiKey } + : { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' }; const response = await requestUrl({ url, diff --git a/src/config.ts b/src/config.ts index e10fa1a..074d1a9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ export interface DiscourseSyncSettings { disUser: string; category: number; skipH1: boolean; + userApiKey?: string; // 新增 } export const DEFAULT_SETTINGS: DiscourseSyncSettings = { @@ -15,7 +16,8 @@ export const DEFAULT_SETTINGS: DiscourseSyncSettings = { apiKey: "apikey", disUser: "DiscourseUsername", category: 1, - skipH1: false + skipH1: false, + userApiKey: "" // 新增 }; export class DiscourseSyncSettingsTab extends PluginSettingTab { @@ -79,6 +81,60 @@ export class DiscourseSyncSettingsTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName("User-Api-Key") + .setDesc("通过Discourse授权后获得的User-Api-Key,优先用于API请求") + .addText((text) => + text + .setPlaceholder("user_api_key") + .setValue(this.plugin.settings.userApiKey || "") + .setDisabled(true) + ) + .addButton((button: ButtonComponent) => { + button.setButtonText("生成User-Api-Key"); + button.onClick(async () => { + const { generateKeyPairAndNonce, saveKeyPair, loadKeyPair, clearKeyPair } = await import("./crypto"); + const pair = generateKeyPairAndNonce(); + saveKeyPair(pair); + const url = `${this.plugin.settings.baseUrl.replace(/\/$/,"")}/user-api-key/new?` + + `application_name=Obsidian%20Discourse%20Plugin&client_id=obsidian-${Date.now()}&scopes=read,write&public_key=${encodeURIComponent(pair.publicKeyPem)}&nonce=${pair.nonce}`; + window.open(url, '_blank'); + new Notice("已生成密钥对并跳转授权页面,请授权后粘贴payload。", 8000); + this.display(); + }); + }); + + // payload输入框和解密按钮 + new Setting(containerEl) + .setName("解密payload") + .setDesc("请粘贴Discourse返回的payload,自动解密user-api-key") + .addText((text) => { + text.setPlaceholder("payload base64"); + text.inputEl.style.width = '80%'; + (text as any).payloadValue = ''; + text.onChange((value) => { + (text as any).payloadValue = value; + }); + }) + .addButton((button: ButtonComponent) => { + button.setButtonText("解密并保存"); + button.onClick(async () => { + const { decryptUserApiKey, clearKeyPair } = await import("./crypto"); + const payload = (containerEl.querySelector('input[placeholder="payload base64"]') as HTMLInputElement)?.value; + if (!payload) { new Notice("请先粘贴payload"); return; } + try { + const userApiKey = await decryptUserApiKey(payload); + this.plugin.settings.userApiKey = userApiKey; + await this.plugin.saveSettings(); + clearKeyPair(); + new Notice("User-Api-Key解密成功!", 5000); + this.display(); + } catch (e) { + new Notice("User-Api-Key解密失败: " + e, 8000); + } + }); + }); + new Setting(containerEl) .setName(t('TEST_API_KEY')) .setDesc('') diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..deda1f7 --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,50 @@ +// 类型声明修正 +// @ts-ignore +import forge from 'node-forge'; + +const KEY_STORAGE = 'discourse_user_api_keypair'; + +export interface UserApiKeyPair { + publicKeyPem: string; + privateKeyPem: string; + nonce: string; +} + +// 生成RSA密钥对和nonce +export function generateKeyPairAndNonce(): UserApiKeyPair { + const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096, e: 0x10001 }); + const publicKeyPem = forge.pki.publicKeyToPem(keypair.publicKey); + const privateKeyPem = forge.pki.privateKeyToPem(keypair.privateKey); + const nonce = forge.util.bytesToHex(forge.random.getBytesSync(16)); + return { publicKeyPem, privateKeyPem, nonce }; +} + +// 保存密钥对到localStorage +export function saveKeyPair(pair: UserApiKeyPair) { + localStorage.setItem(KEY_STORAGE, JSON.stringify(pair)); +} + +// 读取密钥对 +export function loadKeyPair(): UserApiKeyPair | null { + const raw = localStorage.getItem(KEY_STORAGE); + if (!raw) return null; + return JSON.parse(raw); +} + +// 清除密钥对 +export function clearKeyPair() { + localStorage.removeItem(KEY_STORAGE); +} + +// 解密payload,校验nonce,返回user-api-key +export async function decryptUserApiKey(payload: string): Promise { + const pair = loadKeyPair(); + if (!pair) throw new Error('请先生成密钥对'); + const privateKey = forge.pki.privateKeyFromPem(pair.privateKeyPem); + const encryptedBytes = forge.util.decode64(payload.trim().replace(/\s/g, '')); + const decrypted = privateKey.decrypt(encryptedBytes, 'RSAES-PKCS1-V1_5'); + const json = JSON.parse(decrypted); + if (!json.key) throw new Error('payload内容无key字段'); + if (json.nonce !== pair.nonce) throw new Error('nonce校验失败'); + return json.key; +} \ No newline at end of file