feat: 新增halo博客模式并修复跨域问题

This commit is contained in:
appleex 2024-10-14 19:22:36 +08:00
parent a2abc83109
commit ba6a1380a6
11 changed files with 616 additions and 27 deletions

View file

@ -12,19 +12,25 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node": "^16.11.6",
"@types/object-path": "^0.11.4",
"@types/qs": "^6.9.16",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"date-fns": "^4.1.0",
"esbuild": "0.17.3",
"esbuild-copy-static-files": "^0.1.0",
"file-type-checker": "^1.1.2",
"lodash-es": "^4.17.21",
"object-path": "^0.11.8",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"axios": "^1.7.7",
"object-path": "^0.11.8"
"qs": "^6.13.0"
}
}

View file

@ -4,19 +4,44 @@ const config = {
text: 'Lskypro',
value: 'lsky',
},
{
text: 'Halo',
value: 'halo',
},
{
text: 'Github',
value: 'github',
},
],
reqHeader: {
"Authorization": "Bearer Jq7mTKdjN7BxMjLoG8Jj8eaGGV2GuurwumOGFCBm",
"Accept": "application/json",
"Content-Type": "multipart/form-data"
lskySetting: {
"apiURL": "https://img.elinzy.com/api/v1/upload",
"apiReqHeader": {
"Authorization": "Bearer Jq7mTKdjN7BxMjLoG8Jj8eaGGV2GuurwumOGFCBm",
"Accept": "application/json",
"Content-Type": "multipart/form-data"
},
"apiReqBody": {
"file": "$FILE",
"strategy_id": 2
},
"imgUrlPath": "data.pathname",
"imgUrlPrefix": "https://blog.elinzy.com/upload/eblog/"
},
reqBody: {
"file": "$FILE",
"strategy_id": 2
haloSetting: {
// See: https://api.halo.run/#/
"apiURL": "https://blog.elinzy.com/apis/api.console.halo.run/v1alpha1/attachments/upload",
"apiReqHeader": {
"Authorization": "Bearer pat_eyJraWQiOiI1SG9MUWVvUW10bV9zd2JISFRSODFEd1dUWVNyUUlyc0N3NjE2R1dQTXpnIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2Jsb2cuZWxpbnp5LmNvbS8iLCJzdWIiOiJhZG1pbiIsImlhdCI6MTcxMTk1NjY5MiwianRpIjoiN2E5NDY3NjEtY2QzYi1kODc4LTJkOGMtZWQxNmUwZDk4NzFjIiwicGF0X25hbWUiOiJwYXQtYWRtaW4tbkdnZG0ifQ.cl7n0f9SqP5qwYUW2ZM6DL6Ex6Xh9GvxL_hwEDJuUn8tlyuzOhcjUqkGSCO1D8E0lqym7RtV7xDQzSLkQybKbSwRqeMEDbLbdLMDpzD2Vu1aptz5m0ifsm9pvpm2Wqnn_px68arhqHqmMwP-8N9F1m-fMu1ovBuXI2gzHA56Ne1yWsSObeasxqNP1CNsez_abdv0fPKxbBsV_2JrQeHYc0y90M8V0WZ9d3_zdrgOMg6Hm481VprrHUwB9nxrsVsCxd_ROwf93ZhGo_VPV5YG8_Pz4CwRGzkIABVHO1leUXyMD24nOpG9csGo-mauuik7oi1h6eLUMi2902qKFUctwRsF4lKQSP6h2Li9UUt0FYQp78uaTDbke3eAxLq1nqWfOn3Lplpe1tz8MPXtZGclx2xz4zkQWs5TdOU4cuukMIJoXfc1VCbAR9AuaG1BVMZYnL-scgg9I9GNHjtcfgFMskUaEKQlyYTNiIweaqQHMlBHvdxJjCMko9jdB2qnMNw9cs2QfMreK278A6ucHMOB9WGVh0thpor1gk2MvAcV3k-ai21BKUdOLooxftdXbI7V9xif_fLEZwFvtf2RtRqChJpXLyGS2-xoOwg00kz0khckv43vntSCPZ8pqHnTAWsy_ssf80WOajs2guY25l04PXAshEGndvziB0Aod3AoXdY",
"Content-Type": "multipart/form-data",
"Host": "blog.elinzy.com",
},
"apiReqBody": {
"file": "$FILE",
"policyName": "attachment-policy-gUpSV", // 策略
"groupName": "attachment-group-JWlvm" // 分组
},
"imgUrlPath": "data.metadata.annotations['storage.halo.run/uri']",
"imgUrlPrefix": "https://blog.elinzy.com/"
}
}

86
src/haloUpload.ts Normal file
View file

@ -0,0 +1,86 @@
// Defaults
import objectPath from "object-path";
import fileTypeChecker from 'file-type-checker';
// Types
import { IUploader, IUploadPluginSettings} from "./type";
// Utils
import { RestClient } from './utils/rest-client';
import { FormItems, Media, fileToArrayBuffer } from './utils/utils';
import {Notice} from "obsidian";
// Class
class HaloUpload implements IUploader {
private readonly settings: IUploadPluginSettings;
private readonly client: RestClient;
constructor(settings: IUploadPluginSettings) {
this.settings = settings;
this.client = new RestClient();
}
async upload(file: File, options?: any): Promise<string> {
return new Promise(async (resolve, reject) => {
const uploadBody = JSON.parse(this.settings.haloSetting.apiReqBody);
const [buf, filename] = await fileToArrayBuffer(file);
const fileType = fileTypeChecker.detectFile(buf);
const media: Media = {
mimeType: fileType?.mimeType ?? 'application/octet-stream',
fileName: filename,
content: buf
}
const formItems = new FormItems();
for (const key in uploadBody) {
if (uploadBody[key] == "$FILE") {
formItems.append(key, media)
}
else {
formItems.append(key, uploadBody[key])
}
}
// NOTE: Obsidian 是基于 Electron 开发的,对于请求 Halo 的接口来说相当于是在浏览器上请求的,所以存在跨域问题。
// See: https://github.com/halo-sigs/obsidian-halo/issues/2
// axios.post(this.settings.haloSetting.apiURL, formData, {
// "headers": JSON.parse(this.settings.haloSetting.apiReqHeader),
// withCredentials: true,
// }).then(res => {
// const url = [this.settings.haloSetting.imgUrlPrefix, objectPath.get(res.data, this.settings.haloSetting.imgUrlPath)].join('')
//
// resolve(url)
// }, err => {
// reject(err)
// })
this.client.httpPost(
this.settings.haloSetting.apiURL,
formItems,
{
headers: JSON.parse(this.settings.haloSetting.apiReqHeader),
}
)
.then((res: {}) => {
const url = [
this.settings.haloSetting.imgUrlPrefix,
objectPath.get(res, JSON.parse(this.settings.haloSetting.imgUrlPath))
].join('');
resolve(url)
})
.catch(err => {
reject(err)
});
})
}
//
drawSettingsTab(parentEL: HTMLDivElement) {
}
}
export default HaloUpload;

View file

@ -1,26 +1,27 @@
// Defaults
import { IUploadPluginSettings } from "./type";
import { IUploadPluginSettings, IUploader } from "./type";
import axios from "axios";
import objectPath from "object-path";
import {Notice} from "obsidian";
class LskyUpload {
class LskyUpload implements IUploader {
private readonly settings: IUploadPluginSettings;
constructor(settings: IUploadPluginSettings) {
this.settings = settings;
}
async upload(file: File, fullPath?: string): Promise<string> {
async upload(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const formData = new FormData();
const uploadBody = JSON.parse(this.settings.lskySetting.apiReqBody);
for (const key in uploadBody) {
if (uploadBody[key] == "$FILE") {
formData.append(key, file, file.name)
formData.append(key, file, file.name);
}
else {
formData.append(key, uploadBody[key])
formData.append(key, uploadBody[key]);
}
}

View file

@ -1,5 +1,4 @@
import {
App,
Editor,
MarkdownView,
Notice,
@ -44,7 +43,14 @@ const DEFAULT_SETTINGS: IUploadPluginSettings = {
apiReqBody: "{\"file\": \"$FILE\"}",
imgUrlPath: "",
imgUrlPrefix: "",
}
},
haloSetting: {
apiURL: "",
apiReqHeader: "",
apiReqBody: "{\"file\": \"$FILE\"}",
imgUrlPath: "",
imgUrlPrefix: "",
},
}
class UploadPlugin extends Plugin {
@ -63,7 +69,9 @@ class UploadPlugin extends Plugin {
// 赋值
this.helper = new Helper(this.app);
this.uploader = uploader(this.settings);
// 初始化设置 uploader
this.setupUploader();
// 图标
addIcon(
@ -150,7 +158,7 @@ class UploadPlugin extends Plugin {
// 自定义方法
// 右键菜单上传文件
async menuUploadFile(file: TFile) {
async menuUploadFileTest(file: TFile) {
new Notice('准备上传');
let content = this.helper.getValue();
@ -232,6 +240,93 @@ class UploadPlugin extends Plugin {
});
}
async menuUploadFile(file: TFile) {
if (!file) {
new Notice('不合法文件,请重新尝试');
return;
}
if (!this.uploader) {
new Notice("未配置上传器,请检查设置");
return;
}
new Notice('准备上传');
let content = this.helper.getValue();
const basePath = this.helper.getBasePath();
const fileArray = this.helper.getAllFiles();
let imageList: IImage[] = [];
// 查找文档中相同路径文件
for (const match of fileArray) {
const imageName = match.name;
const encodedUri = match.path;
const fileName = basename(decodeURI(encodedUri));
if (file && file.name === fileName) {
const abstractImageFile = join(basePath, file.path);
if (isAssetTypeAnImage(abstractImageFile)) {
imageList.push({
path: abstractImageFile,
name: imageName,
source: match.source,
});
}
}
}
if (imageList.length === 0) {
new Notice("没有匹配到文件");
return;
}
const files = [];
// 通过文件路径读取文件,并转化为文件格式
for (let i = 0; i < imageList.length; i++) {
const { path, name } = imageList[i];
const fileName = basename(decodeURI(path));
const buffer: Buffer = await new Promise((resolve, reject) => {
readFile(path, (err, data) => {
if (err) {
reject(err);
}
resolve(data);
});
});
const arrayBuffer = bufferToArrayBuffer(buffer);
files.push(new File([arrayBuffer], fileName));
}
if (files.length === 0) {
new Notice("文件读取失败");
return;
}
// 通过uploader上传文件
this.uploader.upload(files[0])
.then(res => {
// 文件上传成功后替换文档中相同路径文件的link
imageList.map(item => {
content = content.replaceAll(
item.source,
`![](${res})`
);
});
// 更新文档
this.helper.setValue(content);
new Notice("上传成功");
})
.catch(err => {
new Notice(`上传失败: ${err}`);
});
}
// 注册右键菜单事件
registerMenuEvent() {
this.registerEvent(
@ -256,6 +351,15 @@ class UploadPlugin extends Plugin {
)
);
}
// 设置 uploader
setupUploader() {
try {
this.uploader = uploader(this.settings);
} catch (e) {
console.log(`设置 Uploader 失败: ${e}`);
}
}
}
export default UploadPlugin;

View file

@ -60,8 +60,9 @@ class UploadSettingsTab extends PluginSettingTab {
e.setValue(this.plugin.settings.mode)
e.onChange(async value => {
this.plugin.settings.mode = value;
// NOTE: 重新设置uploader
this.plugin.setupUploader();
await this.plugin.saveSettings();
await this.drawModeSettings(this.modeDiv, value);
})
})
@ -76,6 +77,9 @@ class UploadSettingsTab extends PluginSettingTab {
case 'lsky':
this.drawLskySettings(parentEl);
break;
case 'halo':
this.drawHaloSettings(parentEl);
break;
case 'github':
this.drawGithubSettings(parentEl);
break;
@ -186,6 +190,103 @@ class UploadSettingsTab extends PluginSettingTab {
})
}
// Halo 设置
private drawHaloSettings(parentEL: HTMLDivElement) {
// Api URL
new Setting(parentEL)
.setName("API 地址")
.setDesc("请求地址")
.addText(text => {
text
.setPlaceholder("")
.setValue(this.plugin.settings.haloSetting.apiURL)
.onChange(async (value) => {
try {
this.plugin.settings.haloSetting.apiURL = value;
await this.plugin.saveSettings();
}
catch (e) {
console.log(e)
}
})
})
// Api request header
new Setting(parentEL)
.setName("POST Header")
.setDesc("请求头json 格式")
.addTextArea((text) => {
text.setPlaceholder("输入合法的请求头")
.setValue(this.plugin.settings.haloSetting.apiReqHeader)
.onChange(async (value) => {
try {
this.plugin.settings.haloSetting.apiReqHeader = value;
await this.plugin.saveSettings();
}
catch (e) {
console.log(e)
}
})
text.inputEl.rows = 5
text.inputEl.cols = 40
})
// Api request body
new Setting(parentEL)
.setName("POST Body")
.setDesc("请求体json 格式")
.addTextArea((text) => {
text.setPlaceholder("输入合法的请求体")
.setValue(this.plugin.settings.haloSetting.apiReqBody)
.onChange(async (value) => {
try {
this.plugin.settings.haloSetting.apiReqBody = value;
await this.plugin.saveSettings();
}
catch (e) {
console.log(e)
}
})
text.inputEl.rows = 5
text.inputEl.cols = 40
})
new Setting(parentEL)
.setName("图片 URL 路径")
.setDesc("返回json数据中的图片URL字段的路径data/pathname")
.addText(text => {
text
.setPlaceholder("")
.setValue(this.plugin.settings.haloSetting.imgUrlPath)
.onChange(async (value) => {
try {
this.plugin.settings.haloSetting.imgUrlPath = value;
await this.plugin.saveSettings();
}
catch (e) {
console.log(e)
}
})
})
new Setting(parentEL)
.setName("图片 URL 前缀")
.setDesc("可选当填入时URL = 前缀 + 图片的路径值")
.addText(text => {
text
.setPlaceholder("")
.setValue(this.plugin.settings.haloSetting.imgUrlPrefix)
.onChange(async (value) => {
try {
this.plugin.settings.haloSetting.imgUrlPrefix = value;
await this.plugin.saveSettings();
}
catch (e) {
console.log(e)
}
})
})
}
// Github 设置
private drawGithubSettings(parentEL: HTMLDivElement) {}
}

View file

@ -22,8 +22,16 @@ export interface IUploadPluginSettings {
imgUrlPrefix: string;
lskySetting: ILskySettings;
haloSetting: ILskySettings;
}
export interface IUploader {
upload(file: File, fullPath?: string): Promise<string>;
upload(file: File | IMedia, options?: Record<any, any>): Promise<string>;
}
export interface IMedia {
mimeType: string;
fileName: string;
content: ArrayBuffer;
}
export type SafeAny = any; // eslint-disable-line @typescript-eslint/no-explicit-any

View file

@ -1,4 +1,5 @@
import LskyUpload from "./lskyUpload";
import HaloUpload from "./haloUpload";
// Types
import { IUploadPluginSettings, IUploader } from './type';
@ -7,6 +8,8 @@ function Uploader(settings: IUploadPluginSettings): IUploader {
switch (settings.mode) {
case 'lsky':
return new LskyUpload(settings);
case 'halo':
return new HaloUpload(settings);
default:
throw new Error('should not reach here!')
}

View file

@ -1,4 +1,4 @@
import { MarkdownView, App } from "obsidian";
import { MarkdownView, App, TFile, FileSystemAdapter } from "obsidian";
import { parse } from "path";
interface Image {
@ -119,4 +119,14 @@ export default class Helper {
return blackDomainList.some(blackDomain => domain.includes(blackDomain));
}
async readBinary(file: TFile) {
return this.app.vault.readBinary(file);
}
getBasePath() {
return (
this.app.vault.adapter as FileSystemAdapter
).getBasePath()
}
}

81
src/utils/rest-client.ts Normal file
View file

@ -0,0 +1,81 @@
import { requestUrl } from 'obsidian';
import { getBoundary, FormItems, type FormItemNameMapper } from './utils';
interface RestOptions {
url: URL;
}
export class RestClient {
constructor(
private readonly options?: RestOptions
) {
console.log(options);
}
async httpGet(
url: string,
options?: {
headers: Record<string, string>
}
): Promise<unknown> {
const endpoint = url;
const opts = {
headers: {},
...options
};
console.log('REST GET', endpoint, opts);
const response = await requestUrl({
url: endpoint,
method: 'GET',
headers: {
'content-type': 'application/json',
'user-agent': 'obsidian.md',
...opts.headers
}
});
console.log('GET response', response);
return response.json;
}
async httpPost(
url: string,
body: any,
options?: {
headers: Record<string, string>;
formItemNameMapper?: FormItemNameMapper;
}
): Promise<unknown> {
const endpoint = url;
const predefinedHeaders: Record<string, string> = {};
let requestBody: any;
if (body instanceof FormItems) {
const boundary = getBoundary();
requestBody = await body.toArrayBuffer({
boundary,
nameMapper: options?.formItemNameMapper
});
predefinedHeaders['content-type'] = `multipart/form-data; boundary=${boundary}`;
} else if (body instanceof ArrayBuffer) {
requestBody = body;
} else {
requestBody = JSON.stringify(body);
predefinedHeaders['content-type'] = 'application/json';
}
const response = await requestUrl({
url: endpoint,
method: 'POST',
headers: {
'user-agent': 'obsidian.md',
...predefinedHeaders,
...options?.headers
},
body: requestBody
});
console.log('POST response', response);
return response.json;
}
}

View file

@ -1,4 +1,8 @@
import { resolve, extname, relative, join, parse, posix } from "path";
import { resolve, extname, relative, join, parse, posix, basename} from "path";
import { format } from 'date-fns';
import { isArray, isFunction, isString, template } from 'lodash-es';
import { SafeAny, IMedia } from "../type";
const IMAGE_EXT_LIST = [
".png",
".jpg",
@ -11,14 +15,30 @@ const IMAGE_EXT_LIST = [
".avif",
];
export function isAnImage(ext: string) {
return IMAGE_EXT_LIST.includes(ext.toLowerCase());
}
export function isAssetTypeAnImage(path: string): Boolean {
return isAnImage(extname(path));
/**
* File ArrayBuffer
* @param file
*/
export async function fileToArrayBuffer(file: File) {
return new Promise<any>((resolve, reject) => {
const fr = new FileReader();
const filename = file.name;
fr.readAsArrayBuffer(file);
fr.addEventListener("loadend",(e: any) => {
const buf = e.target.result;
resolve([buf, filename]);
},false);
fr.addEventListener("error", (e: any) => {
reject(e);
})
})
}
/**
* buffer ArrayBuffer
* @param buffer
*/
export function bufferToArrayBuffer(buffer: Buffer) {
const arrayBuffer = new ArrayBuffer(buffer.length);
const view = new Uint8Array(arrayBuffer);
@ -27,3 +47,147 @@ export function bufferToArrayBuffer(buffer: Buffer) {
}
return arrayBuffer;
}
/**
* boundary
*/
export function getBoundary(): string {
return `----obsidianBoundary${format(new Date(), 'yyyyMMddHHmmss')}`;
}
/**
*
* @param ext
*/
export function isAnImage(ext: string) {
return IMAGE_EXT_LIST.includes(ext.toLowerCase());
}
/**
*
* @param path
*/
export function isAssetTypeAnImage(path: string): Boolean {
return isAnImage(extname(path));
}
type UrlGetter = () => string;
/**
* URL
* @param url
* @param defaultValue
* @param params
*/
export function getUrl(
url: string | UrlGetter | undefined,
defaultValue: string,
params?: { [p: string]: string | number }
): string {
let resultUrl: string;
if (isString(url)) {
resultUrl = url;
} else if (isFunction(url)) {
resultUrl = url();
} else {
resultUrl = defaultValue;
}
if (params) {
const compiled = template(resultUrl);
return compiled(params);
} else {
return resultUrl;
}
}
/**
* Media
* @param obj
*/
export function isMedia(obj: any): obj is IMedia {
return (
typeof obj === 'object'
&& obj !== null
&& 'mimeType' in obj && typeof obj.mimeType === 'string'
&& 'fileName' in obj && typeof obj.fileName === 'string'
&& 'content' in obj && obj.content instanceof ArrayBuffer
);
}
export interface Media {
mimeType: string;
fileName: string;
content: ArrayBuffer;
}
/**
* Convert original item name to custom one.
*
* @param name original item name. If `isArray` is `true`, which means is in an array, the `name` will be appended by `[]`
* @param isArray whether this item is in an array
*/
export type FormItemNameMapper = (name: string, isArray: boolean) => string;
export class FormItems {
#formData: Record<string, SafeAny> = {};
append(name: string, data: string): FormItems;
append(name: string, data: IMedia): FormItems;
append(name: string, data: string | IMedia): FormItems {
const existing = this.#formData[name];
if (existing) {
this.#formData[name] = [ existing ];
this.#formData[name].push(data);
} else {
this.#formData[name] = data;
}
return this;
}
toArrayBuffer(option: {
boundary: string;
nameMapper?: FormItemNameMapper;
}): Promise<ArrayBuffer> {
const CRLF = '\r\n';
const itemPart = (name: string, data: string | IMedia, isArray: boolean) => {
let itemName = name;
if (option.nameMapper) {
itemName = option.nameMapper(name, isArray);
}
body.push(encodedItemStart);
if (isString(data)) {
body.push(encoder.encode(`Content-Disposition: form-data; name="${itemName}"${CRLF}${CRLF}`));
body.push(encoder.encode(data));
} else {
const media = data;
body.push(encoder.encode(`Content-Disposition: form-data; name="${itemName}"; filename="${media.fileName}"${CRLF}Content-Type: ${media.mimeType}${CRLF}${CRLF}`));
body.push(media.content);
}
body.push(encoder.encode(CRLF));
};
const encoder = new TextEncoder();
const encodedItemStart = encoder.encode(`--${option.boundary}${CRLF}`);
const body: ArrayBuffer[] = [];
Object.entries(this.#formData).forEach(([ name, data ]) => {
if (isArray(data)) {
data.forEach(item => {
itemPart(`${name}[]`, item, true);
});
} else {
itemPart(name, data, false);
}
});
body.push(encoder.encode(`--${option.boundary}--`));
return new Blob(body).arrayBuffer();
}
}
export function formItemNameMapper(name: string, isArray: boolean): string {
if (name === 'file' && !isArray) {
return 'media[]';
}
return name;
}