diff --git a/README-EN.md b/README-EN.md
index aa70bf1..ffe5be8 100644
--- a/README-EN.md
+++ b/README-EN.md
@@ -158,6 +158,28 @@ docker compose --profile redis up -d --build
For full Docker guide, see [README-DOCKER.md](README-DOCKER.md).
+### WebDAV Regression Validation (Works for Pages and Docker)
+
+After deployment, run at least one WebDAV smoke check to verify the full flow: config test -> upload -> download -> delete.
+
+Example:
+
+```bash
+BASE_URL=https://your-domain \
+BASIC_USER=admin BASIC_PASS=your_password \
+SMOKE_STORAGE_TYPE=webdav \
+SMOKE_STORAGE_CONFIG_JSON='{"baseUrl":"https://dav.example.com","username":"u","password":"p","rootPath":"uploads"}' \
+node scripts/storage-regression.js
+```
+
+Validation criteria:
+
+- `webdav.connected` in `/api/status` must be `true`
+- `/api/storage/:id/test` must return `connected=true`
+- WebDAV `upload / download / delete` in the regression script must all pass
+
+For Docker deployment, simply change `BASE_URL` to your self-hosted address, for example `http://localhost:8080`.
+
---
## Storage Configuration
diff --git a/README.md b/README.md
index fed3bad..3c20322 100644
--- a/README.md
+++ b/README.md
@@ -136,6 +136,28 @@ docker compose --profile redis up -d --build
完整 Docker 说明请查看 [README-DOCKER.md](README-DOCKER.md)。
+### WebDAV 回归验证(Cloudflare Pages / Docker 通用)
+
+部署完成后,建议至少执行一次 WebDAV 烟测,确认“配置测试 -> 上传 -> 下载 -> 删除”完整闭环。
+
+示例:
+
+```bash
+BASE_URL=https://你的域名 \
+BASIC_USER=admin BASIC_PASS=your_password \
+SMOKE_STORAGE_TYPE=webdav \
+SMOKE_STORAGE_CONFIG_JSON='{"baseUrl":"https://dav.example.com","username":"u","password":"p","rootPath":"uploads"}' \
+node scripts/storage-regression.js
+```
+
+校验标准:
+
+- `/api/status` 中 `webdav.connected` 必须为 `true`
+- `/api/storage/:id/test` 必须返回 `connected=true`
+- 回归脚本中的 WebDAV `upload / download / delete` 三步必须全部通过
+
+如果是 Docker 部署,只需把 `BASE_URL` 换成你的自托管地址,例如 `http://localhost:8080`。
+
### Docker 登录 API(curl 示例)
`/api/auth/login` 同时兼容两种请求体:
diff --git a/functions/utils/webdav.js b/functions/utils/webdav.js
index 042f8a0..1b009f4 100644
--- a/functions/utils/webdav.js
+++ b/functions/utils/webdav.js
@@ -140,6 +140,21 @@ async function fetchDavWithAuthMode(config, method, storagePath = '', mode = 'no
});
}
+function buildAuthAttemptModes(config) {
+ const attemptedModes = [];
+ const pushMode = (mode) => {
+ if (!mode || attemptedModes.includes(mode)) return;
+ attemptedModes.push(mode);
+ };
+
+ pushMode(authMode(config));
+ if (hasBearerAuth(config)) pushMode('bearer');
+ if (hasBasicAuth(config)) pushMode('basic');
+ pushMode('none');
+
+ return attemptedModes;
+}
+
async function ensureCollectionPath(config, storagePath) {
const rootSegments = splitPath(config.rootPath);
const fileSegments = splitPath(storagePath);
@@ -220,16 +235,7 @@ export async function getWebDAVFile(storagePath, env = {}, options = {}) {
headers.Range = options.range;
}
- const attemptedModes = [];
- const pushMode = (mode) => {
- if (!mode || attemptedModes.includes(mode)) return;
- attemptedModes.push(mode);
- };
-
- pushMode(authMode(config));
- if (hasBearerAuth(config)) pushMode('bearer');
- if (hasBasicAuth(config)) pushMode('basic');
- pushMode('none');
+ const attemptedModes = buildAuthAttemptModes(config);
let firstFailure = null;
@@ -294,65 +300,51 @@ export async function checkWebDAVConnection(env = {}) {
let lastDetail = '';
let lastStatus = null;
- for (const rootUrl of rootCandidates) {
- const authHeaders = buildAuthHeaders(config, { Depth: '0' });
- const optionsResponse = await fetch(rootUrl, {
- method: 'OPTIONS',
- headers: authHeaders,
- });
+ const attemptedModes = buildAuthAttemptModes(config);
- if (optionsResponse.ok) {
- return {
- connected: true,
- configured: true,
- status: optionsResponse.status,
- message: 'Connected',
- };
+ for (const mode of attemptedModes) {
+ for (const rootUrl of rootCandidates) {
+ const optionsResponse = await fetch(rootUrl, {
+ method: 'OPTIONS',
+ headers: buildAuthOverrideHeaders(config, mode, { Depth: '0' }),
+ });
+
+ if (optionsResponse.ok) {
+ return {
+ connected: true,
+ configured: true,
+ status: optionsResponse.status,
+ message: 'Connected',
+ };
+ }
+
+ if (![400, 401, 403, 405].includes(optionsResponse.status)) {
+ lastStatus = optionsResponse.status;
+ lastDetail = await decodeErrorTextSafe(optionsResponse);
+ }
+
+ const propfindResponse = await fetch(rootUrl, {
+ method: 'PROPFIND',
+ headers: buildAuthOverrideHeaders(config, mode, {
+ Depth: '0',
+ 'Content-Type': 'application/xml; charset=utf-8',
+ }),
+ body: propfindBody,
+ });
+
+ const connected = propfindResponse.ok || propfindResponse.status === 207;
+ if (connected) {
+ return {
+ connected: true,
+ configured: true,
+ status: propfindResponse.status,
+ message: 'Connected',
+ };
+ }
+
+ lastStatus = propfindResponse.status;
+ lastDetail = await decodeErrorTextSafe(propfindResponse);
}
-
- if ([401, 403].includes(optionsResponse.status)) {
- const detail = await decodeErrorTextSafe(optionsResponse);
- return {
- connected: false,
- configured: true,
- status: optionsResponse.status,
- message: 'Authentication failed',
- detail: detail || 'Authentication failed',
- };
- }
-
- const propfindResponse = await fetch(rootUrl, {
- method: 'PROPFIND',
- headers: buildAuthHeaders(config, {
- Depth: '0',
- 'Content-Type': 'application/xml; charset=utf-8',
- }),
- body: propfindBody,
- });
-
- const connected = propfindResponse.ok || propfindResponse.status === 207;
- if (connected) {
- return {
- connected: true,
- configured: true,
- status: propfindResponse.status,
- message: 'Connected',
- };
- }
-
- if ([401, 403].includes(propfindResponse.status)) {
- const detail = await decodeErrorTextSafe(propfindResponse);
- return {
- connected: false,
- configured: true,
- status: propfindResponse.status,
- message: 'Authentication failed',
- detail: detail || 'Authentication failed',
- };
- }
-
- lastStatus = propfindResponse.status;
- lastDetail = await decodeErrorTextSafe(propfindResponse);
}
return {
diff --git a/scripts/storage-regression.js b/scripts/storage-regression.js
index 5b21fe5..25aad52 100644
--- a/scripts/storage-regression.js
+++ b/scripts/storage-regression.js
@@ -133,6 +133,13 @@ async function checkHealthAndStatus() {
}
}
logOk('/api/status includes all storage keys');
+
+ if (SMOKE_STORAGE_TYPE) {
+ const smokeStatus = status.payload?.[SMOKE_STORAGE_TYPE];
+ if (smokeStatus?.configured && !smokeStatus?.connected) {
+ throw new Error(`/api/status reports ${SMOKE_STORAGE_TYPE} disconnected: ${smokeStatus.message || smokeStatus.detail || 'unknown error'}`);
+ }
+ }
}
function parseSmokeConfig() {
@@ -189,6 +196,9 @@ async function storageCrudAndSelection() {
});
const testBody = createdTest.payload || {};
const testResult = testBody.result || testBody;
+ if (!testResult.connected) {
+ throw new Error(`created storage test failed: ${testResult.detail || testResult.message || 'unknown error'}`);
+ }
logOk(`created storage test -> connected=${Boolean(testResult.connected)} status=${testResult.status || 'n/a'}`);
await request(`/api/storage/default/${encodeURIComponent(created.id)}`, {
@@ -290,9 +300,9 @@ async function cleanupStorage(createdId, originalDefaultId) {
async function main() {
process.stdout.write(`K-Vault storage regression start\nBASE_URL=${BASE_URL}\n`);
- await checkHealthAndStatus();
await ensureLoginIfNeeded();
const { createdId, originalDefault } = await storageCrudAndSelection();
+ await checkHealthAndStatus();
await uploadDownloadDeleteForEnabledStorages();
await cleanupStorage(createdId, originalDefault);
diff --git a/server/app.js b/server/app.js
index c9b692c..9403134 100644
--- a/server/app.js
+++ b/server/app.js
@@ -1088,19 +1088,24 @@ function createApp() {
const id = decodeURIComponent(c.req.param('id'));
const range = c.req.header('range');
- const result = await uploadService.getFileResponse(id, range);
- if (!result) {
- return c.text('File not found', 404);
+ try {
+ const result = await uploadService.getFileResponse(id, range);
+ if (!result) {
+ return c.text('File not found', 404);
+ }
+
+ const upstream = result.response;
+ const headers = buildFileProxyHeaders(result, upstream.headers);
+
+ return new Response(upstream.body, {
+ status: upstream.status,
+ statusText: upstream.statusText,
+ headers,
+ });
+ } catch (error) {
+ console.error('file proxy route error:', error);
+ return c.text(`File proxy error: ${error?.message || 'Unknown error'}`, 502);
}
-
- const upstream = result.response;
- const headers = buildFileProxyHeaders(result, upstream.headers);
-
- return new Response(upstream.body, {
- status: upstream.status,
- statusText: upstream.statusText,
- headers,
- });
});
app.options('/file/:id', (c) => c.body(null, 204));
@@ -1109,19 +1114,26 @@ function createApp() {
const id = decodeURIComponent(c.req.param('id'));
const range = c.req.header('range');
- const result = await uploadService.getFileResponse(id, range);
- if (!result) {
- return c.body(null, 404);
+ try {
+ const result = await uploadService.getFileResponse(id, range);
+ if (!result) {
+ return c.body(null, 404);
+ }
+
+ const upstream = result.response;
+ const headers = buildFileProxyHeaders(result, upstream.headers);
+
+ return new Response(null, {
+ status: upstream.status,
+ statusText: upstream.statusText,
+ headers,
+ });
+ } catch (error) {
+ console.error('file proxy HEAD route error:', error);
+ return c.body(null, 502, {
+ 'X-File-Proxy-Error': String(error?.message || 'Unknown error').slice(0, 200),
+ });
}
-
- const upstream = result.response;
- const headers = buildFileProxyHeaders(result, upstream.headers);
-
- return new Response(null, {
- status: upstream.status,
- statusText: upstream.statusText,
- headers,
- });
});
app.get('/share/:id', async (c) => {
@@ -1143,20 +1155,25 @@ function createApp() {
return c.text('Invalid share signature.', 403);
}
- const result = await uploadService.getFileResponse(fileId, range);
- if (!result) {
- return c.text('File not found', 404);
+ try {
+ const result = await uploadService.getFileResponse(fileId, range);
+ if (!result) {
+ return c.text('File not found', 404);
+ }
+
+ const upstream = result.response;
+ const headers = buildFileProxyHeaders(result, upstream.headers);
+ headers.set('Cache-Control', 'private, max-age=60');
+
+ return new Response(upstream.body, {
+ status: upstream.status,
+ statusText: upstream.statusText,
+ headers,
+ });
+ } catch (error) {
+ console.error('share proxy route error:', error);
+ return c.text(`Share proxy error: ${error?.message || 'Unknown error'}`, 502);
}
-
- const upstream = result.response;
- const headers = buildFileProxyHeaders(result, upstream.headers);
- headers.set('Cache-Control', 'private, max-age=60');
-
- return new Response(upstream.body, {
- status: upstream.status,
- statusText: upstream.statusText,
- headers,
- });
});
app.options('/share/:id', (c) => c.body(null, 204));
diff --git a/server/lib/storage/adapters/webdav.js b/server/lib/storage/adapters/webdav.js
index 32cf873..40cd87b 100644
--- a/server/lib/storage/adapters/webdav.js
+++ b/server/lib/storage/adapters/webdav.js
@@ -44,6 +44,29 @@ function authMode(config) {
return 'none';
}
+function hasBasicAuth(config) {
+ return Boolean(config.username && config.password);
+}
+
+function hasBearerAuth(config) {
+ return Boolean(config.bearerToken);
+}
+
+function buildAuthAttemptModes(config) {
+ const attemptedModes = [];
+ const pushMode = (mode) => {
+ if (!mode || attemptedModes.includes(mode)) return;
+ attemptedModes.push(mode);
+ };
+
+ pushMode(authMode(config));
+ if (hasBearerAuth(config)) pushMode('bearer');
+ if (hasBasicAuth(config)) pushMode('basic');
+ pushMode('none');
+
+ return attemptedModes;
+}
+
function decodeResponseTextSafe(response) {
return response.text().catch(() => '');
}
@@ -83,6 +106,21 @@ class WebDAVStorageAdapter {
return headers;
}
+ getAuthHeadersForMode(mode = 'none', extra = {}) {
+ const headers = { ...extra };
+ if (mode === 'bearer' && hasBearerAuth(this.config)) {
+ headers.Authorization = `Bearer ${this.config.bearerToken}`;
+ return headers;
+ }
+ if (mode === 'basic' && hasBasicAuth(this.config)) {
+ const token = Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64');
+ headers.Authorization = `Basic ${token}`;
+ return headers;
+ }
+ delete headers.Authorization;
+ return headers;
+ }
+
buildSegments(storageKey = '') {
return [...splitPath(this.config.rootPath), ...splitPath(storageKey)];
}
@@ -100,6 +138,14 @@ class WebDAVStorageAdapter {
});
}
+ async fetchDavForMode(method, storageKey = '', mode = 'none', { headers = {}, body = null } = {}) {
+ return fetch(this.buildUrl(storageKey), {
+ method,
+ headers: this.getAuthHeadersForMode(mode, headers),
+ body,
+ });
+ }
+
async testConnection() {
this.validate();
@@ -115,58 +161,49 @@ class WebDAVStorageAdapter {
let lastStatus = null;
let lastDetail = '';
- for (const rootUrl of rootCandidates) {
- const optionsResponse = await fetch(rootUrl, {
- method: 'OPTIONS',
- headers: this.getAuthHeaders({ Depth: '0' }),
- });
+ const attemptedModes = buildAuthAttemptModes(this.config);
- if (optionsResponse.ok) {
- return {
- connected: true,
- status: optionsResponse.status,
+ for (const mode of attemptedModes) {
+ for (const rootUrl of rootCandidates) {
+ const optionsResponse = await fetch(rootUrl, {
method: 'OPTIONS',
- };
- }
+ headers: this.getAuthHeadersForMode(mode, { Depth: '0' }),
+ });
- if (optionsResponse.status === 401 || optionsResponse.status === 403) {
- return {
- connected: false,
- status: optionsResponse.status,
- method: 'OPTIONS',
- detail: 'Authentication failed for WebDAV endpoint.',
- };
- }
+ if (optionsResponse.ok) {
+ return {
+ connected: true,
+ status: optionsResponse.status,
+ method: 'OPTIONS',
+ };
+ }
- const propfindResponse = await fetch(rootUrl, {
- method: 'PROPFIND',
- headers: this.getAuthHeaders({
- Depth: '0',
- 'Content-Type': 'application/xml; charset=utf-8',
- }),
- body: propfindBody,
- });
+ if (![400, 401, 403, 405].includes(optionsResponse.status)) {
+ lastStatus = optionsResponse.status;
+ lastDetail = await decodeResponseTextSafe(optionsResponse);
+ }
- const connected = propfindResponse.ok || propfindResponse.status === 207;
- if (connected) {
- return {
- connected: true,
- status: propfindResponse.status,
+ const propfindResponse = await fetch(rootUrl, {
method: 'PROPFIND',
- };
- }
+ headers: this.getAuthHeadersForMode(mode, {
+ Depth: '0',
+ 'Content-Type': 'application/xml; charset=utf-8',
+ }),
+ body: propfindBody,
+ });
- if (propfindResponse.status === 401 || propfindResponse.status === 403) {
- return {
- connected: false,
- status: propfindResponse.status,
- method: 'PROPFIND',
- detail: 'Authentication failed for WebDAV endpoint.',
- };
- }
+ const connected = propfindResponse.ok || propfindResponse.status === 207;
+ if (connected) {
+ return {
+ connected: true,
+ status: propfindResponse.status,
+ method: 'PROPFIND',
+ };
+ }
- lastStatus = propfindResponse.status;
- lastDetail = await decodeResponseTextSafe(propfindResponse);
+ lastStatus = propfindResponse.status;
+ lastDetail = await decodeResponseTextSafe(propfindResponse);
+ }
}
return {
@@ -260,13 +297,42 @@ class WebDAVStorageAdapter {
const headers = {};
if (range) headers.Range = range;
- const response = await this.fetchDav('GET', storageKey, { headers });
- if (!response.ok && response.status !== 206) {
+ const attemptedModes = [];
+ const pushMode = (mode) => {
+ if (!mode || attemptedModes.includes(mode)) return;
+ attemptedModes.push(mode);
+ };
+
+ pushMode(authMode(this.config));
+ if (hasBearerAuth(this.config)) pushMode('bearer');
+ if (hasBasicAuth(this.config)) pushMode('basic');
+ pushMode('none');
+
+ let firstFailure = null;
+
+ for (const mode of attemptedModes) {
+ const response = await this.fetchDavForMode('GET', storageKey, mode, { headers });
+ if (response.ok || response.status === 206) {
+ return response;
+ }
if (response.status === 404) return null;
- const detail = await decodeResponseTextSafe(response);
- throw new Error(`WebDAV download failed (${response.status}): ${detail || 'Unknown error'}`);
+
+ if (!firstFailure) {
+ const detail = await decodeResponseTextSafe(response);
+ firstFailure = { status: response.status, detail };
+ }
+
+ if (![400, 401, 403].includes(response.status)) {
+ const detail = await decodeResponseTextSafe(response);
+ throw new Error(`WebDAV download failed (${response.status}): ${detail || 'Unknown error'}`);
+ }
}
- return response;
+
+ if (firstFailure) {
+ throw new Error(`WebDAV download failed (${firstFailure.status}): ${firstFailure.detail || 'Unknown error'}`);
+ }
+
+ throw new Error('WebDAV download failed: Unknown error');
}
async delete({ storageKey }) {
diff --git a/test/server-webdav-adapter.test.js b/test/server-webdav-adapter.test.js
new file mode 100644
index 0000000..50b623a
--- /dev/null
+++ b/test/server-webdav-adapter.test.js
@@ -0,0 +1,109 @@
+const assert = require('assert');
+const { WebDAVStorageAdapter } = require('../server/lib/storage/adapters/webdav');
+
+describe('Server WebDAV adapter download fallback', function () {
+ const originalFetch = global.fetch;
+
+ afterEach(function () {
+ global.fetch = originalFetch;
+ });
+
+ it('falls back from bearer to basic auth', async function () {
+ const seenAuth = [];
+ let callCount = 0;
+
+ global.fetch = async (_url, init = {}) => {
+ callCount += 1;
+ const auth = init?.headers?.Authorization || '';
+ seenAuth.push(auth);
+ if (callCount === 1) {
+ return new Response('unauthorized', { status: 401 });
+ }
+ return new Response('ok', { status: 200 });
+ };
+
+ const adapter = new WebDAVStorageAdapter({
+ baseUrl: 'https://example.com/dav',
+ bearerToken: 'bad-token',
+ username: 'user',
+ password: 'pass',
+ });
+
+ const response = await adapter.download({ storageKey: 'uploads/file.png' });
+ const body = await response.text();
+
+ assert.strictEqual(response.status, 200);
+ assert.strictEqual(body, 'ok');
+ assert.strictEqual(callCount, 2);
+ assert.ok(String(seenAuth[0]).startsWith('Bearer '));
+ assert.ok(String(seenAuth[1]).startsWith('Basic '));
+ });
+
+ it('falls back to anonymous when authenticated reads are rejected', async function () {
+ const seenAuth = [];
+ let callCount = 0;
+
+ global.fetch = async (_url, init = {}) => {
+ callCount += 1;
+ const auth = init?.headers?.Authorization || '';
+ seenAuth.push(auth);
+ if (callCount === 1) {
+ return new Response('forbidden', { status: 403 });
+ }
+ return new Response('public', { status: 200 });
+ };
+
+ const adapter = new WebDAVStorageAdapter({
+ baseUrl: 'https://example.com/dav',
+ username: 'user',
+ password: 'pass',
+ });
+
+ const response = await adapter.download({ storageKey: 'uploads/public.png' });
+ const body = await response.text();
+
+ assert.strictEqual(response.status, 200);
+ assert.strictEqual(body, 'public');
+ assert.strictEqual(callCount, 2);
+ assert.ok(String(seenAuth[0]).startsWith('Basic '));
+ assert.strictEqual(String(seenAuth[1]), '');
+ });
+
+ it('returns null on 404', async function () {
+ global.fetch = async () => new Response('missing', { status: 404 });
+
+ const adapter = new WebDAVStorageAdapter({
+ baseUrl: 'https://example.com/dav',
+ username: 'user',
+ password: 'pass',
+ });
+
+ const response = await adapter.download({ storageKey: 'uploads/missing.png' });
+ assert.strictEqual(response, null);
+ });
+
+ it('testConnection falls back from bearer to basic auth', async function () {
+ const seenAuth = [];
+
+ global.fetch = async (_url, init = {}) => {
+ const auth = init?.headers?.Authorization || '';
+ seenAuth.push(auth);
+ if (String(auth).startsWith('Bearer ')) {
+ return new Response('unauthorized', { status: 401 });
+ }
+ return new Response('', { status: 207 });
+ };
+
+ const adapter = new WebDAVStorageAdapter({
+ baseUrl: 'https://example.com/dav',
+ bearerToken: 'bad-token',
+ username: 'user',
+ password: 'pass',
+ });
+
+ const result = await adapter.testConnection();
+ assert.strictEqual(result.connected, true);
+ assert.ok(String(seenAuth[0]).startsWith('Bearer '));
+ assert.ok(seenAuth.some((value) => String(value).startsWith('Basic ')));
+ });
+});
\ No newline at end of file
diff --git a/test/webdav-download.test.js b/test/webdav-download.test.js
index 4ab9459..40cb5f3 100644
--- a/test/webdav-download.test.js
+++ b/test/webdav-download.test.js
@@ -83,4 +83,30 @@ describe('WebDAV download auth fallback', function () {
const response = await getWebDAVFile('uploads/missing.png', env);
assert.strictEqual(response, null);
});
+
+ it('connection check falls back from bearer to basic auth', async function () {
+ const authHeaders = [];
+
+ global.fetch = async (_url, init = {}) => {
+ const auth = init?.headers?.Authorization || '';
+ authHeaders.push(auth);
+ if (String(auth).startsWith('Bearer ')) {
+ return new Response('unauthorized', { status: 401 });
+ }
+ return new Response('', { status: 207 });
+ };
+
+ const { checkWebDAVConnection } = await import('../functions/utils/webdav.js');
+ const env = {
+ WEBDAV_BASE_URL: 'https://example.com/dav',
+ WEBDAV_BEARER_TOKEN: 'bad-token',
+ WEBDAV_USERNAME: 'user',
+ WEBDAV_PASSWORD: 'pass',
+ };
+
+ const result = await checkWebDAVConnection(env);
+ assert.strictEqual(result.connected, true);
+ assert.ok(String(authHeaders[0]).startsWith('Bearer '));
+ assert.ok(authHeaders.some((value) => String(value).startsWith('Basic ')));
+ });
});