From e2cdca9da090275a689a0bc82cda379fc39966ea Mon Sep 17 00:00:00 2001 From: brycepollack <87786776+brycepollack@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:23:46 -0700 Subject: [PATCH] feat(settings): Add custom headers option (#158) * feat(settings): Add custom headers option * add customHeaders to fs/webdav/api * eslint to oxlint --- src/components/SelectRemoteBaseDirModal.ts | 2 ++ src/fs/webdav/api.ts | 14 ++++++++++++- src/fs/webdav/traverse.ts | 18 ++++++++++++++--- src/fs/webdav/utils.ts | 14 ++++++++++--- src/i18n/en.ts | 5 +++++ src/i18n/ru.ts | 5 +++++ src/i18n/zh-Hans.ts | 5 +++++ src/i18n/zh-Hant.ts | 5 +++++ src/index.ts | 1 + src/services/webdav.service.ts | 1 + src/settings/account.ts | 23 ++++++++++++++++++++++ src/settings/index.ts | 1 + src/utils/parse-headers.ts | 18 +++++++++++++++++ test/traverse-webdav.test.ts | 3 +++ 14 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 src/utils/parse-headers.ts diff --git a/src/components/SelectRemoteBaseDirModal.ts b/src/components/SelectRemoteBaseDirModal.ts index 4194979..21bd403 100644 --- a/src/components/SelectRemoteBaseDirModal.ts +++ b/src/components/SelectRemoteBaseDirModal.ts @@ -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), diff --git a/src/fs/webdav/api.ts b/src/fs/webdav/api.ts index ace66f0..a7df9a0 100644 --- a/src/fs/webdav/api.ts +++ b/src/fs/webdav/api.ts @@ -136,11 +136,13 @@ const PROPFIND_BODY = ` `; +// oxlint-disable-next-line max-params async function propfind( endpoint: string, token: string, url: string, depth: '0' | '1' | 'infinity', + customHeaders: Record = {}, ) { 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 { +export async function getStat( + endpoint: string, + token: string, + path: string, + customHeaders: Record = {}, +): Promise { 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 = {}, ): Promise> { const contents: Array = []; let currentUrl = buildDirectoryUrl(endpoint, path); @@ -214,6 +225,7 @@ export async function getDirectoryContents( token, currentUrl, infinity ? 'infinity' : '1', + customHeaders, ); const parsedItems = items diff --git a/src/fs/webdav/traverse.ts b/src/fs/webdav/traverse.ts index 97d748d..a3f7994 100644 --- a/src/fs/webdav/traverse.ts +++ b/src/fs/webdav/traverse.ts @@ -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; diff --git a/src/fs/webdav/utils.ts b/src/fs/webdav/utils.ts index 0d22180..2f6877d 100644 --- a/src/fs/webdav/utils.ts +++ b/src/fs/webdav/utils.ts @@ -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) { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index bf92d07..fe7eaa4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 13f66c2..b5c1d78 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -66,6 +66,11 @@ const translation: typeof en = { name: 'Пароль / Токен', placeholder: 'Введите пароль или токен', }, + customHeaders: { + desc: 'Необязательный параметр. По одному на строку, в формате «Имя-заголовка: значение». Передается с каждым запросом WebDAV.', + name: 'Пользовательские заголовки', + placeholder: 'X-Api-Key: abc123', + }, encryption: { desc: 'Шифровать файлы перед загрузкой и расшифровывать при скачивании. Пароль шифрования будет сохранён в связке ключей Obsidian.', name: 'Шифрование', diff --git a/src/i18n/zh-Hans.ts b/src/i18n/zh-Hans.ts index 7ce9255..6324ffc 100644 --- a/src/i18n/zh-Hans.ts +++ b/src/i18n/zh-Hans.ts @@ -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: '加密', diff --git a/src/i18n/zh-Hant.ts b/src/i18n/zh-Hant.ts index a0f3dc8..d91d5a7 100644 --- a/src/i18n/zh-Hant.ts +++ b/src/i18n/zh-Hant.ts @@ -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: '加密', diff --git a/src/index.ts b/src/index.ts index edb7308..643dee0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ export default class WebDAVSyncPlugin extends Plugin { confirmBeforeDeleteInAutoSync: true, confirmBeforeSync: true, conflictStrategy: ConflictStrategy.DiffMatchPatch, + customHeaders: {}, encryption: { enabled: false, value: '', diff --git a/src/services/webdav.service.ts b/src/services/webdav.service.ts index e542ab7..9b29040 100644 --- a/src/services/webdav.service.ts +++ b/src/services/webdav.service.ts @@ -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, }); diff --git a/src/settings/account.ts b/src/settings/account.ts index 5ac829d..6f45648 100644 --- a/src/settings/account.ts +++ b/src/settings/account.ts @@ -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) => + 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) diff --git a/src/settings/index.ts b/src/settings/index.ts index f03d88f..454140e 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -35,6 +35,7 @@ export type PluginSettings = { serverUrl: string; account: string; token: string; + customHeaders: Record; encryption: { enabled: boolean; value: string; diff --git a/src/utils/parse-headers.ts b/src/utils/parse-headers.ts new file mode 100644 index 0000000..ba23707 --- /dev/null +++ b/src/utils/parse-headers.ts @@ -0,0 +1,18 @@ +const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +export default function parseHeaders(raw: string) { + const headers: Record = {}; + + 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; +} diff --git a/test/traverse-webdav.test.ts b/test/traverse-webdav.test.ts index 1d92b75..eeaa992 100644 --- a/test/traverse-webdav.test.ts +++ b/test/traverse-webdav.test.ts @@ -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, ); });