mirror of
https://github.com/woodchen-ink/obsidian-publish-to-discourse.git
synced 2025-07-18 05:42:05 +08:00
增加user-api-key 设置选项,支持用户自助获取。
This commit is contained in:
parent
48cbc72c4a
commit
441d9c3688
10
package-lock.json
generated
10
package-lock.json
generated
@ -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",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-forge": "^1.3.1",
|
||||
"yaml": "^2.7.0"
|
||||
}
|
||||
}
|
||||
|
72
src/api.ts
72
src/api.ts
@ -38,10 +38,15 @@ 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({
|
||||
@ -68,10 +73,15 @@ 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 = {
|
||||
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,
|
||||
"Api-Key": this.settings.apiKey || '',
|
||||
"Api-Username": this.settings.disUser || ''
|
||||
};
|
||||
|
||||
try {
|
||||
@ -141,10 +151,15 @@ 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 = {
|
||||
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,
|
||||
"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,
|
||||
|
@ -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
50
src/crypto.ts
Normal 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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user