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 '))); + }); });