feat: 增加WebDAV认证回退机制,优化文件下载与连接检查逻辑

This commit is contained in:
katelya
2026-03-16 23:13:50 +08:00
parent 4e3b291493
commit f02531973f
8 changed files with 418 additions and 154 deletions

View File

@@ -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

View File

@@ -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 登录 APIcurl 示例)
`/api/auth/login` 同时兼容两种请求体:

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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));

View File

@@ -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 }) {

View File

@@ -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('<?xml version="1.0"?><multistatus/>', { 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 ')));
});
});

View File

@@ -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('<?xml version="1.0"?><multistatus/>', { 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 ')));
});
});