feat(settings): Add custom headers option (#158)

* feat(settings): Add custom headers option

* add customHeaders to fs/webdav/api

* eslint to oxlint
This commit is contained in:
brycepollack 2026-06-21 21:23:46 -07:00 committed by GitHub
parent 550d44f50c
commit e2cdca9da0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 108 additions and 7 deletions

View file

@ -27,6 +27,8 @@ export default class SelectRemoteBaseDirModal extends Modal {
this.plugin.settings.serverUrl,
token,
target,
false,
this.plugin.settings.customHeaders,
);
return items.map((stat) => ({
basename: remoteBasename(stat.path),

View file

@ -136,11 +136,13 @@ const PROPFIND_BODY = `<?xml version="1.0" encoding="utf-8"?>
</prop>
</propfind>`;
// oxlint-disable-next-line max-params
async function propfind(
endpoint: string,
token: string,
url: string,
depth: '0' | '1' | 'infinity',
customHeaders: Record<string, string> = {},
) {
let retries = 0;
while (true)
@ -148,6 +150,7 @@ async function propfind(
const response = await requestUrl({
body: PROPFIND_BODY,
headers: {
...customHeaders,
Authorization: `Basic ${token}`,
'Content-Type': 'application/xml',
Depth: depth,
@ -179,12 +182,18 @@ async function propfind(
}
}
export async function getStat(endpoint: string, token: string, path: string): Promise<StatModel> {
export async function getStat(
endpoint: string,
token: string,
path: string,
customHeaders: Record<string, string> = {},
): Promise<StatModel> {
const { items, stripPrefixes } = await propfind(
endpoint,
token,
buildItemUrl(endpoint, path),
'0',
customHeaders,
);
const normalizedTargetPath = normalizeRemotePath(path);
@ -197,11 +206,13 @@ export async function getStat(endpoint: string, token: string, path: string): Pr
throw new Error(`WebDAV stat not found for ${path}`);
}
// oxlint-disable-next-line max-params
export async function getDirectoryContents(
endpoint: string,
token: string,
path: string,
infinity = false,
customHeaders: Record<string, string> = {},
): Promise<Array<StatModel>> {
const contents: Array<StatModel> = [];
let currentUrl = buildDirectoryUrl(endpoint, path);
@ -214,6 +225,7 @@ export async function getDirectoryContents(
token,
currentUrl,
infinity ? 'infinity' : '1',
customHeaders,
);
const parsedItems = items

View file

@ -28,13 +28,25 @@ export default async function traverse({
token,
throwIfCancelled,
}: TraverseWebDAVOptions) {
const { filterRules, skipLargeFiles, serverUrl, remoteDir, exhaustiveRemoteTraversal } =
await useSettings();
const {
filterRules,
skipLargeFiles,
serverUrl,
remoteDir,
exhaustiveRemoteTraversal,
customHeaders,
} = await useSettings();
const encrypted = (await useSettings()).encryption.enabled;
const result: StatsMap = new Map();
const getContentFunc = (path: string) =>
apiLimiter.wrap(getDirectoryContents)(serverUrl, token, path, exhaustiveRemoteTraversal);
apiLimiter.wrap(getDirectoryContents)(
serverUrl,
token,
path,
exhaustiveRemoteTraversal,
customHeaders,
);
const getContent = async (path: string) => {
let retryCount = 0;

View file

@ -6,9 +6,17 @@ import { getStat } from './api';
export async function statItem(path: string, statPath = path) {
const plugin = await usePlugin();
return Object.assign(await getStat(plugin.settings.serverUrl, plugin.getToken(), path), {
statPath,
});
return Object.assign(
await getStat(
plugin.settings.serverUrl,
plugin.getToken(),
path,
plugin.settings.customHeaders,
),
{
statPath,
},
);
}
export async function getContent(webdav: WebDAVClient, path: string) {

View file

@ -64,6 +64,11 @@ const en = {
name: 'Credential',
placeholder: 'Enter your credential',
},
customHeaders: {
desc: 'Optional. One per line, as "Header-Name: value". Sent on every WebDAV request.',
name: 'Custom headers',
placeholder: 'X-Api-Key: abc123',
},
encryption: {
desc: 'Encrypt files before upload and decrypt files when download. Encryption password will be stored in Obsidian keychain.',
name: 'Encryption',

View file

@ -66,6 +66,11 @@ const translation: typeof en = {
name: 'Пароль / Токен',
placeholder: 'Введите пароль или токен',
},
customHeaders: {
desc: 'Необязательный параметр. По одному на строку, в формате «Имя-заголовка: значение». Передается с каждым запросом WebDAV.',
name: 'Пользовательские заголовки',
placeholder: 'X-Api-Key: abc123',
},
encryption: {
desc: 'Шифровать файлы перед загрузкой и расшифровывать при скачивании. Пароль шифрования будет сохранён в связке ключей Obsidian.',
name: 'Шифрование',

View file

@ -66,6 +66,11 @@ const translation: typeof en = {
name: '凭证',
placeholder: '输入你的凭证',
},
customHeaders: {
desc: '可选。每行一个格式为“Header-Name: value”。随每个 WebDAV 请求发送。',
name: '自定义标头',
placeholder: 'X-Api-Key: abc123',
},
encryption: {
desc: '在上传前加密文件,并在下载时解密文件。密码将存储在 Obsidian 的密钥链中。',
name: '加密',

View file

@ -66,6 +66,11 @@ const zhHant: typeof en = {
name: '憑證',
placeholder: '請輸入憑證',
},
customHeaders: {
desc: '可選。每行一個格式為「Header-Name: value」。隨每個 WebDAV 請求發送。',
name: '自訂標頭',
placeholder: 'X-Api-Key: abc123',
},
encryption: {
desc: '上傳前加密檔案,下載時解密檔案。加密密碼將儲存於 Obsidian 鑰匙圈中。',
name: '加密',

View file

@ -42,6 +42,7 @@ export default class WebDAVSyncPlugin extends Plugin {
confirmBeforeDeleteInAutoSync: true,
confirmBeforeSync: true,
conflictStrategy: ConflictStrategy.DiffMatchPatch,
customHeaders: {},
encryption: {
enabled: false,
value: '',

View file

@ -22,6 +22,7 @@ export class WebDAVService {
createWebDAVClient(): WebDAVClient {
const client = createClient(this.plugin.settings.serverUrl, {
headers: this.plugin.settings.customHeaders,
password: getCredential(this.plugin),
username: this.plugin.settings.account,
});

View file

@ -5,6 +5,7 @@ import SelectRemoteBaseDirModal from '~/components/SelectRemoteBaseDirModal';
import t from '~/i18n';
import { normalizeBaseDir } from '~/platform/path';
import handleInput from '~/utils/handle-input';
import parseHeaders from '~/utils/parse-headers';
import BaseSettings from './settings.base';
export default class AccountSettings extends BaseSettings {
@ -69,6 +70,28 @@ export default class AccountSettings extends BaseSettings {
}),
);
new Setting(this.containerEl)
.setName(t('settings.customHeaders.name'))
.setDesc(t('settings.customHeaders.desc'))
.addTextArea((textarea) => {
const stringify = (h: Record<string, string>) =>
Object.entries(h)
.map(([k, v]) => `${k}: ${v}`)
.join('\n');
textarea
.setPlaceholder(t('settings.customHeaders.placeholder'))
.setValue(stringify(this.plugin.settings.customHeaders));
textarea.inputEl.addEventListener('blur', () => {
const parsed = parseHeaders(textarea.getValue());
if (parsed === false) new Notice(t('settings.invalidValue'));
else {
this.plugin.settings.customHeaders = parsed;
void this.plugin.saveSettings();
}
textarea.setValue(stringify(this.plugin.settings.customHeaders));
});
});
this.displayCheckConnection();
new Setting(this.containerEl)

View file

@ -35,6 +35,7 @@ export type PluginSettings = {
serverUrl: string;
account: string;
token: string;
customHeaders: Record<string, string>;
encryption: {
enabled: boolean;
value: string;

View file

@ -0,0 +1,18 @@
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
export default function parseHeaders(raw: string) {
const headers: Record<string, string> = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed === '') continue;
const colon = trimmed.indexOf(':');
if (colon === -1) return false;
const name = trimmed.slice(0, colon).trim();
const value = trimmed.slice(colon + 1).trim();
if (!HEADER_NAME.test(name)) return false;
headers[name] = value;
}
return headers;
}

View file

@ -24,6 +24,7 @@ test('uses remote-base-aware path when traversing child directories', async () =
'token',
'/test/',
false,
undefined,
);
expect(getDirectoryContentsMock).toHaveBeenNthCalledWith(
2,
@ -31,6 +32,7 @@ test('uses remote-base-aware path when traversing child directories', async () =
'token',
'/test/webdav-sync/',
false,
undefined,
);
});
@ -50,5 +52,6 @@ test('skips missing directories during traversal', async () => {
'token',
'/test/missing/',
false,
undefined,
);
});