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",
|
"version": "1.0.20",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
"yaml": "^2.7.0"
|
"yaml": "^2.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -1852,6 +1853,15 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/obsidian": {
|
||||||
"version": "1.5.7-1",
|
"version": "1.5.7-1",
|
||||||
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.5.7-1.tgz",
|
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.5.7-1.tgz",
|
||||||
|
@ -26,6 +26,7 @@
|
|||||||
"typescript": "4.7.4"
|
"typescript": "4.7.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
"yaml": "^2.7.0"
|
"yaml": "^2.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
82
src/api.ts
82
src/api.ts
@ -38,11 +38,16 @@ export class DiscourseAPI {
|
|||||||
formDataArray.set(endBoundaryArray, imgFormArray.length + bodyArray.length + imgfile.byteLength);
|
formDataArray.set(endBoundaryArray, imgFormArray.length + bodyArray.length + imgfile.byteLength);
|
||||||
|
|
||||||
const url = `${this.settings.baseUrl}/uploads.json`;
|
const url = `${this.settings.baseUrl}/uploads.json`;
|
||||||
const headers = {
|
const headers: Record<string, string> = this.settings.userApiKey
|
||||||
"Api-Key": this.settings.apiKey,
|
? {
|
||||||
"Api-Username": this.settings.disUser,
|
"User-Api-Key": this.settings.userApiKey,
|
||||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
"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({
|
const response = await requestUrl({
|
||||||
url: url,
|
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 }> {
|
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 url = `${this.settings.baseUrl}/posts.json`;
|
||||||
const headers = {
|
const headers: Record<string, string> = this.settings.userApiKey
|
||||||
"Content-Type": "application/json",
|
? {
|
||||||
"Api-Key": this.settings.apiKey,
|
"User-Api-Key": this.settings.userApiKey,
|
||||||
"Api-Username": this.settings.disUser,
|
"Content-Type": "application/json"
|
||||||
};
|
}
|
||||||
|
: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Api-Key": this.settings.apiKey || '',
|
||||||
|
"Api-Username": this.settings.disUser || ''
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await requestUrl({
|
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 }> {
|
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 postEndpoint = `${this.settings.baseUrl}/posts/${postId}`;
|
||||||
const topicEndpoint = `${this.settings.baseUrl}/t/${topicId}`;
|
const topicEndpoint = `${this.settings.baseUrl}/t/${topicId}`;
|
||||||
const headers = {
|
const headers: Record<string, string> = this.settings.userApiKey
|
||||||
"Content-Type": "application/json",
|
? {
|
||||||
"Api-Key": this.settings.apiKey,
|
"User-Api-Key": this.settings.userApiKey,
|
||||||
"Api-Username": this.settings.disUser,
|
"Content-Type": "application/json"
|
||||||
};
|
}
|
||||||
|
: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Api-Key": this.settings.apiKey || '',
|
||||||
|
"Api-Username": this.settings.disUser || ''
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 首先更新帖子内容
|
// 首先更新帖子内容
|
||||||
@ -222,10 +237,9 @@ export class DiscourseAPI {
|
|||||||
async fetchCategories(): Promise<{ id: number; name: string }[]> {
|
async fetchCategories(): Promise<{ id: number; name: string }[]> {
|
||||||
try {
|
try {
|
||||||
const url = `${this.settings.baseUrl}/categories.json?include_subcategories=true`;
|
const url = `${this.settings.baseUrl}/categories.json?include_subcategories=true`;
|
||||||
const headers = {
|
const headers: Record<string, string> = this.settings.userApiKey
|
||||||
"Api-Key": this.settings.apiKey,
|
? { "User-Api-Key": this.settings.userApiKey }
|
||||||
"Api-Username": this.settings.disUser,
|
: { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' };
|
||||||
};
|
|
||||||
|
|
||||||
const response = await requestUrl({
|
const response = await requestUrl({
|
||||||
url,
|
url,
|
||||||
@ -272,10 +286,9 @@ export class DiscourseAPI {
|
|||||||
async fetchTags(): Promise<{ name: string; canCreate: boolean }[]> {
|
async fetchTags(): Promise<{ name: string; canCreate: boolean }[]> {
|
||||||
try {
|
try {
|
||||||
const url = `${this.settings.baseUrl}/tags.json`;
|
const url = `${this.settings.baseUrl}/tags.json`;
|
||||||
const headers = {
|
const headers: Record<string, string> = this.settings.userApiKey
|
||||||
"Api-Key": this.settings.apiKey,
|
? { "User-Api-Key": this.settings.userApiKey }
|
||||||
"Api-Username": this.settings.disUser,
|
: { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' };
|
||||||
};
|
|
||||||
|
|
||||||
const response = await requestUrl({
|
const response = await requestUrl({
|
||||||
url,
|
url,
|
||||||
@ -313,10 +326,9 @@ export class DiscourseAPI {
|
|||||||
async checkCanCreateTags(): Promise<boolean> {
|
async checkCanCreateTags(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const url = `${this.settings.baseUrl}/site.json`;
|
const url = `${this.settings.baseUrl}/site.json`;
|
||||||
const headers = {
|
const headers: Record<string, string> = this.settings.userApiKey
|
||||||
"Api-Key": this.settings.apiKey,
|
? { "User-Api-Key": this.settings.userApiKey }
|
||||||
"Api-Username": this.settings.disUser,
|
: { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' };
|
||||||
};
|
|
||||||
|
|
||||||
const response = await requestUrl({
|
const response = await requestUrl({
|
||||||
url,
|
url,
|
||||||
@ -340,7 +352,7 @@ export class DiscourseAPI {
|
|||||||
|
|
||||||
// 测试API密钥
|
// 测试API密钥
|
||||||
async testApiKey(): Promise<{ success: boolean; message: string }> {
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: t('MISSING_SETTINGS')
|
message: t('MISSING_SETTINGS')
|
||||||
@ -349,10 +361,9 @@ export class DiscourseAPI {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${this.settings.baseUrl}/users/${this.settings.disUser}.json`;
|
const url = `${this.settings.baseUrl}/users/${this.settings.disUser}.json`;
|
||||||
const headers = {
|
const headers: Record<string, string> = this.settings.userApiKey
|
||||||
"Api-Key": this.settings.apiKey,
|
? { "User-Api-Key": this.settings.userApiKey }
|
||||||
"Api-Username": this.settings.disUser,
|
: { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' };
|
||||||
};
|
|
||||||
|
|
||||||
const response = await requestUrl({
|
const response = await requestUrl({
|
||||||
url,
|
url,
|
||||||
@ -392,10 +403,9 @@ export class DiscourseAPI {
|
|||||||
async fetchTopicInfo(topicId: number): Promise<{ tags: string[], categoryId?: number }> {
|
async fetchTopicInfo(topicId: number): Promise<{ tags: string[], categoryId?: number }> {
|
||||||
try {
|
try {
|
||||||
const url = `${this.settings.baseUrl}/t/${topicId}.json`;
|
const url = `${this.settings.baseUrl}/t/${topicId}.json`;
|
||||||
const headers = {
|
const headers: Record<string, string> = this.settings.userApiKey
|
||||||
"Api-Key": this.settings.apiKey,
|
? { "User-Api-Key": this.settings.userApiKey }
|
||||||
"Api-Username": this.settings.disUser,
|
: { "Api-Key": this.settings.apiKey || '', "Api-Username": this.settings.disUser || '' };
|
||||||
};
|
|
||||||
|
|
||||||
const response = await requestUrl({
|
const response = await requestUrl({
|
||||||
url,
|
url,
|
||||||
|
@ -8,6 +8,7 @@ export interface DiscourseSyncSettings {
|
|||||||
disUser: string;
|
disUser: string;
|
||||||
category: number;
|
category: number;
|
||||||
skipH1: boolean;
|
skipH1: boolean;
|
||||||
|
userApiKey?: string; // 新增
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: DiscourseSyncSettings = {
|
export const DEFAULT_SETTINGS: DiscourseSyncSettings = {
|
||||||
@ -15,7 +16,8 @@ export const DEFAULT_SETTINGS: DiscourseSyncSettings = {
|
|||||||
apiKey: "apikey",
|
apiKey: "apikey",
|
||||||
disUser: "DiscourseUsername",
|
disUser: "DiscourseUsername",
|
||||||
category: 1,
|
category: 1,
|
||||||
skipH1: false
|
skipH1: false,
|
||||||
|
userApiKey: "" // 新增
|
||||||
};
|
};
|
||||||
|
|
||||||
export class DiscourseSyncSettingsTab extends PluginSettingTab {
|
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)
|
new Setting(containerEl)
|
||||||
.setName(t('TEST_API_KEY'))
|
.setName(t('TEST_API_KEY'))
|
||||||
.setDesc('')
|
.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