增加user-api-key 设置选项,支持用户自助获取。

This commit is contained in:
cumany 2025-07-11 22:08:17 +08:00
parent 48cbc72c4a
commit 441d9c3688
5 changed files with 164 additions and 37 deletions

10
package-lock.json generated
View File

@ -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",

View File

@ -26,6 +26,7 @@
"typescript": "4.7.4"
},
"dependencies": {
"node-forge": "^1.3.1",
"yaml": "^2.7.0"
}
}

View File

@ -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<string, string> = 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<string, string> = 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<string, string> = 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<string, string> = 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<string, string> = 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<boolean> {
try {
const url = `${this.settings.baseUrl}/site.json`;
const headers = {
"Api-Key": this.settings.apiKey,
"Api-Username": this.settings.disUser,
};
const headers: Record<string, string> = 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<string, string> = 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<string, string> = this.settings.userApiKey
? { "User-Api-Key": this.settings.userApiKey }
: { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' };
const response = await requestUrl({
url,

View File

@ -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('')

50
src/crypto.ts Normal file
View File

@ -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<string> {
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;
}