Merge pull request #536 from htazq/feature/webdav-storage-channel

Enable third-party WebDAV storage without new runtime dependencies
This commit is contained in:
叁月柒
2026-04-28 16:31:37 +08:00
committed by GitHub
20 changed files with 911 additions and 14 deletions
+20 -1
View File
@@ -1,6 +1,6 @@
<div align="center">
<a href="https://github.com/MarSeventh/CloudFlare-ImgBed"><img width="80%" alt="logo" src="readme/banner.png"/></a>
<p><em>🗂️Open-source file hosting solution, supporting Docker and serverless deployment, supporting multiple storage channels such as Telegram, Discord, Cloudflare R2, S3, Huggingface, etc., supporting WebDAV protocol and various RESTful APIs.</em></p>
<p><em>🗂️Open-source file hosting solution, supporting Docker and serverless deployment, supporting multiple storage channels such as Telegram, Discord, Cloudflare R2, S3, Huggingface, WebDAV, etc., supporting WebDAV protocol and various RESTful APIs.</em></p>
<p>
<a href="https://github.com/MarSeventh/CloudFlare-ImgBed/blob/main/README_zh.md">简体中文</a> | <a href="https://github.com/MarSeventh/CloudFlare-ImgBed/blob/main/README.md">English</a> | <a
href="https://cfbed.sanyue.de/en">Official Website</a>
@@ -188,6 +188,25 @@ Provides detailed deployment documentation, feature docs, development plans, upd
# 4. Tips
- **WebDAV storage channel**: Besides the built-in `/dav` WebDAV server, this project can also use a third-party WebDAV server as an upload storage channel. Configure a fixed channel with `WEBDAV_BASE_URL`; optional variables are `WEBDAV_USERNAME`, `WEBDAV_PASSWORD`, `WEBDAV_PUBLIC_URL`, `WEBDAV_HEADERS` (JSON object string), and `WEBDAV_CREATE_DIRECTORY=false`. Runtime channel config uses:
```json
{
"webdav": {
"loadBalance": { "enabled": false },
"channels": [{
"name": "dav-main",
"type": "webdav",
"baseUrl": "https://dav.example.com/remote.php/dav/files/user/imgbed/",
"username": "user",
"password": "pass",
"publicUrl": "https://cdn.example.com/imgbed/",
"enabled": true
}]
}
}
```
The implementation uses Fetch/Web APIs only, so it is compatible with Cloudflare Pages Functions and Workers deployments. Basic authentication and no-auth WebDAV endpoints are supported; Digest-only providers are not supported. WebDAV chunked uploads are intentionally disabled, so large uploads must stay within your Cloudflare request body limits.
- Frontend is open source, see [MarSeventh/Sanyue-ImgHub](https://github.com/MarSeventh/Sanyue-ImgHub).
- Desktop software is open source, see [MarSeventh/satellite](https://github.com/MarSeventh/satellite).
+20 -1
View File
@@ -1,6 +1,6 @@
<div align="center">
<a href="https://github.com/MarSeventh/CloudFlare-ImgBed"><img width="80%" alt="logo" src="readme/banner.png"/></a>
<p><em>🗂️开源文件托管解决方案,支持 Docker 和无服务器部署,支持 Telegram、Discord、Cloudflare R2、S3、Huggingface 等多种存储渠道,支持 WebDAV 协议和多种 RESTful API</em></p>
<p><em>🗂️开源文件托管解决方案,支持 Docker 和无服务器部署,支持 Telegram、Discord、Cloudflare R2、S3、Huggingface、WebDAV 等多种存储渠道,支持 WebDAV 协议和多种 RESTful API</em></p>
<p>
<a href="https://github.com/MarSeventh/CloudFlare-ImgBed/blob/main/README_zh.md">简体中文</a> | <a href="https://github.com/MarSeventh/CloudFlare-ImgBed/blob/main/README.md">English</a> | <a href="https://cfbed.sanyue.de">官方网站</a>
</p>
@@ -200,6 +200,25 @@
# 4. Tips
- **WebDAV 存储渠道**:除了内置 `/dav` WebDAV 服务外,本项目也可以把第三方 WebDAV 服务作为上传存储渠道使用。固定环境变量渠道可配置 `WEBDAV_BASE_URL`;可选变量包括 `WEBDAV_USERNAME``WEBDAV_PASSWORD``WEBDAV_PUBLIC_URL``WEBDAV_HEADERS`JSON 对象字符串)和 `WEBDAV_CREATE_DIRECTORY=false`。运行时渠道配置结构如下:
```json
{
"webdav": {
"loadBalance": { "enabled": false },
"channels": [{
"name": "dav-main",
"type": "webdav",
"baseUrl": "https://dav.example.com/remote.php/dav/files/user/imgbed/",
"username": "user",
"password": "pass",
"publicUrl": "https://cdn.example.com/imgbed/",
"enabled": true
}]
}
}
```
实现仅使用 Fetch/Web API,因此可用于 Cloudflare Pages Functions 和 Workers 部署。支持 Basic 认证或无认证 WebDAV 端点;暂不支持仅 Digest 认证的服务。WebDAV 分片上传已明确禁用,大文件需在 Cloudflare 请求体限制范围内直接上传。
- **前端开源**:参见[MarSeventh/Sanyue-ImgHub](https://github.com/MarSeventh/Sanyue-ImgHub)项目。
- **桌面端开源**:参见[MarSeventh/satellite](https://github.com/MarSeventh/satellite)项目。
+7
View File
@@ -23,6 +23,13 @@ not_found_handling = "single-page-application"
[vars]
# TG_BOT_TOKEN = ""
# TG_CHAT_ID = ""
# Optional third-party WebDAV storage channel:
# WEBDAV_BASE_URL = "https://dav.example.com/remote.php/dav/files/user/imgbed/"
# WEBDAV_USERNAME = ""
# WEBDAV_PASSWORD = ""
# WEBDAV_PUBLIC_URL = ""
# WEBDAV_HEADERS = "{\"X-Example\":\"value\"}"
# WEBDAV_CREATE_DIRECTORY = "true"
# [[d1_databases]]
# binding = "img_d1"
File diff suppressed because one or more lines are too long
Binary file not shown.
+4
View File
@@ -55,6 +55,10 @@ export async function onRequest(context) {
huggingface: uploadConfig.huggingface.channels.map(ch => ({
name: ch.name,
type: 'HuggingFace'
})),
webdav: uploadConfig.webdav.channels.map(ch => ({
name: ch.name,
type: 'WebDAV'
}))
};
+32
View File
@@ -4,6 +4,8 @@ import { removeFileFromIndex, batchRemoveFilesFromIndex } from "../../../utils/i
import { getDatabase } from '../../../utils/databaseAdapter.js';
import { DiscordAPI } from '../../../utils/discordAPI.js';
import { HuggingFaceAPI } from '../../../utils/huggingfaceAPI.js';
import { WebDAVAPI } from '../../../utils/webdavAPI.js';
import { resolveWebDAVConfig } from '../../../utils/webdavConfig.js';
// CORS 跨域响应头
const corsHeaders = {
@@ -156,6 +158,11 @@ async function deleteFile(env, fileId, cdnUrl, url) {
await deleteHuggingFaceFile(img);
}
// WebDAV 渠道的图片,需要删除 WebDAV 中对应的文件
if (img.metadata?.Channel === 'WebDAV') {
await deleteWebDAVFile(env, img);
}
// 删除数据库中的记录
// 注意:容量统计现在由索引自动维护,删除文件后索引更新时会自动重新计算
await db.delete(fileId);
@@ -251,3 +258,28 @@ async function deleteHuggingFaceFile(img) {
return false;
}
}
// 删除 WebDAV 渠道的图片
async function deleteWebDAVFile(env, img) {
const filePath = img.metadata?.WebDAVFilePath;
if (!filePath) {
console.warn('WebDAV file missing required metadata for deletion');
return false;
}
try {
const webdavConfig = await resolveWebDAVConfig(env, img.metadata);
if (!webdavConfig) {
console.warn('WebDAV channel config not found for deletion');
return false;
}
const webdavAPI = new WebDAVAPI(webdavConfig);
return await webdavAPI.deleteFile(filePath);
} catch (error) {
console.error("WebDAV Delete Failed:", error);
return false;
}
}
+43 -1
View File
@@ -3,6 +3,8 @@ import { purgeCFCache, purgeRandomFileListCache, purgePublicFileListCache } from
import { moveFileInIndex, batchMoveFilesInIndex } from "../../../utils/indexManager.js";
import { getDatabase } from '../../../utils/databaseAdapter.js';
import { sanitizeUploadFolder } from "../../../upload/uploadTools.js";
import { WebDAVAPI } from "../../../utils/webdavAPI.js";
import { resolveWebDAVConfig } from "../../../utils/webdavConfig.js";
export async function onRequest(context) {
const { request, env, params, waitUntil } = context;
@@ -161,6 +163,23 @@ async function moveFile(env, fileId, newFileId, cdnUrl, url) {
}
}
// WebDAV 渠道的图片,需要移动 WebDAV 中对应的文件
if (img.metadata?.Channel === 'WebDAV') {
const { success, error, webdavConfig } = await moveWebDAVFile(env, img, newFileId);
if (!success) {
throw new Error(error || 'WebDAV Move Failed');
}
img.metadata.WebDAVFilePath = newFileId;
if (img.metadata.WebDAVPublicBaseUrl || img.metadata.WebDAVPublicUrl || webdavConfig?.publicUrl) {
const webdavAPI = new WebDAVAPI(webdavConfig || { baseUrl: img.metadata.WebDAVBaseUrl });
const publicBaseUrl = img.metadata.WebDAVPublicBaseUrl
|| webdavConfig?.publicUrl
|| img.metadata.WebDAVPublicUrl.slice(0, img.metadata.WebDAVPublicUrl.length - fileId.split('/').map(encodeURIComponent).join('/').length);
img.metadata.WebDAVPublicBaseUrl = publicBaseUrl;
img.metadata.WebDAVPublicUrl = webdavAPI.buildPublicUrl(newFileId, publicBaseUrl);
}
}
// 旧版 Telegram 渠道和 Telegraph 渠道不支持移动
if (img.metadata?.Channel === 'Telegram' || img.metadata?.Channel === undefined) {
throw new Error('Unsupported Channel');
@@ -226,4 +245,27 @@ async function moveS3File(img, newFileId) {
console.error("S3 Move Failed:", error);
return { success: false, error: error.message };
}
}
}
// 移动 WebDAV 渠道的图片
async function moveWebDAVFile(env, img, newFileId) {
const oldPath = img.metadata?.WebDAVFilePath;
if (!oldPath) {
return { success: false, error: 'WebDAV file missing required metadata for move' };
}
try {
const webdavConfig = await resolveWebDAVConfig(env, img.metadata);
if (!webdavConfig) {
return { success: false, error: 'WebDAV channel config not found for move' };
}
const webdavAPI = new WebDAVAPI(webdavConfig);
await webdavAPI.moveFile(oldPath, newFileId, true);
return { success: true, newKey: newFileId, webdavConfig };
} catch (error) {
console.error("WebDAV Move Failed:", error);
return { success: false, error: error.message };
}
}
+42
View File
@@ -3,6 +3,8 @@ import { purgeCFCache, purgeRandomFileListCache, purgePublicFileListCache } from
import { moveFileInIndex } from "../../../utils/indexManager.js";
import { getDatabase } from '../../../utils/databaseAdapter.js';
import { sanitizeUploadFolder } from "../../../upload/uploadTools.js";
import { WebDAVAPI } from "../../../utils/webdavAPI.js";
import { resolveWebDAVConfig } from "../../../utils/webdavConfig.js";
// CORS 跨域响应头
const corsHeaders = {
@@ -138,6 +140,23 @@ export async function onRequest(context) {
}
}
// WebDAV 渠道的图片,需要移动 WebDAV 中对应的文件
if (metadata?.Channel === 'WebDAV') {
const { success, error, webdavConfig } = await moveWebDAVFile(env, fileData, newFileId);
if (!success) {
throw new Error(error || 'WebDAV Move Failed');
}
metadata.WebDAVFilePath = newFileId;
if (metadata.WebDAVPublicBaseUrl || metadata.WebDAVPublicUrl || webdavConfig?.publicUrl) {
const webdavAPI = new WebDAVAPI(webdavConfig || { baseUrl: metadata.WebDAVBaseUrl });
const publicBaseUrl = metadata.WebDAVPublicBaseUrl
|| webdavConfig?.publicUrl
|| metadata.WebDAVPublicUrl.slice(0, metadata.WebDAVPublicUrl.length - fileId.split('/').map(encodeURIComponent).join('/').length);
metadata.WebDAVPublicBaseUrl = publicBaseUrl;
metadata.WebDAVPublicUrl = webdavAPI.buildPublicUrl(newFileId, publicBaseUrl);
}
}
// 旧版 Telegram 渠道和 Telegraph 渠道不支持重命名
if (metadata?.Channel === 'Telegram' || metadata?.Channel === undefined) {
return new Response(JSON.stringify({
@@ -228,3 +247,26 @@ async function moveS3File(img, newFileId) {
return { success: false, error: error.message };
}
}
// 移动 WebDAV 渠道的图片
async function moveWebDAVFile(env, img, newFileId) {
const oldPath = img.metadata?.WebDAVFilePath;
if (!oldPath) {
return { success: false, error: 'WebDAV file missing required metadata for move' };
}
try {
const webdavConfig = await resolveWebDAVConfig(env, img.metadata);
if (!webdavConfig) {
return { success: false, error: 'WebDAV channel config not found for move' };
}
const webdavAPI = new WebDAVAPI(webdavConfig);
await webdavAPI.moveFile(oldPath, newFileId, true);
return { success: true, newKey: newFileId, webdavConfig };
} catch (error) {
console.error("WebDAV Move Failed:", error);
return { success: false, error: error.message };
}
}
+2 -1
View File
@@ -152,6 +152,7 @@ export async function getPageConfig(db, env) {
{ label: 'S3', value: 's3' },
{ label: 'Discord', value: 'discord' },
{ label: 'HuggingFace', value: 'huggingface' },
{ label: 'WebDAV', value: 'webdav' },
],
placeholder: 'telegram',
category: '客户端设置',
@@ -315,4 +316,4 @@ export async function getPageConfig(db, env) {
}
return settings
}
}
+62 -1
View File
@@ -1,4 +1,5 @@
import { getDatabase } from '../../../utils/databaseAdapter.js';
import { normalizeWebDAVHeaders } from '../../../utils/webdavAPI.js';
export async function onRequest(context) {
// 上传设置相关,GET方法读取设置,POST方法保存设置
@@ -28,6 +29,14 @@ export async function onRequest(context) {
if (request.method === 'POST') {
const body = await request.json()
const settings = body
// 兼容旧前端包:如果旧包尚未提交 webdav 配置,保留已有 WebDAV 渠道,避免保存其他上传设置时被清空。
if (settings.webdav === undefined) {
const existingSettingsStr = await db.get('manage@sysConfig@upload')
const existingSettings = existingSettingsStr ? JSON.parse(existingSettingsStr) : {}
if (existingSettings.webdav !== undefined) {
settings.webdav = existingSettings.webdav
}
}
// 写入数据库
await db.put('manage@sysConfig@upload', JSON.stringify(settings))
@@ -264,11 +273,63 @@ export async function getUploadConfig(db, env) {
huggingface.loadBalance = huggingfaceLoadBalance
// =====================读取 WebDAV 渠道配置=====================
const webdav = {}
const webdavChannels = []
webdav.channels = webdavChannels
// 从环境变量读取 WebDAV 配置
if (env.WEBDAV_BASE_URL) {
webdavChannels.push({
id: 1,
name: 'WebDAV_env',
type: 'webdav',
savePath: 'environment variable',
baseUrl: env.WEBDAV_BASE_URL,
username: env.WEBDAV_USERNAME || '',
password: env.WEBDAV_PASSWORD || '',
publicUrl: env.WEBDAV_PUBLIC_URL || '',
headers: normalizeWebDAVHeaders(env.WEBDAV_HEADERS || {}),
createDirectory: env.WEBDAV_CREATE_DIRECTORY !== 'false',
enabled: true,
fixed: true,
})
}
for (const wd of settingsKV.webdav?.channels || []) {
// 如果 savePath 是 environment variable,修改可变参数
if (wd.savePath === 'environment variable') {
// 如果环境变量未删除,进行覆盖操作
if (webdavChannels[0]) {
webdavChannels[0].enabled = wd.enabled
webdavChannels[0].publicUrl = wd.publicUrl || webdavChannels[0].publicUrl
webdavChannels[0].headers = normalizeWebDAVHeaders(wd.headers || wd.customHeaders || webdavChannels[0].headers)
webdavChannels[0].createDirectory = wd.createDirectory !== false
webdavChannels[0].quota = wd.quota
}
continue
}
// id 自增
wd.id = webdavChannels.length + 1
wd.headers = normalizeWebDAVHeaders(wd.headers || wd.customHeaders || {})
wd.createDirectory = wd.createDirectory !== false
webdavChannels.push(wd)
}
// 负载均衡
const webdavLoadBalance = settingsKV.webdav?.loadBalance || {
enabled: false,
channels: [],
}
webdav.loadBalance = webdavLoadBalance
settings.telegram = telegram
settings.cfr2 = cfr2
settings.s3 = s3
settings.discord = discord
settings.huggingface = huggingface
settings.webdav = webdav
return settings;
}
}
+78
View File
@@ -3,6 +3,8 @@ import { fetchSecurityConfig } from "../utils/sysConfig";
import { TelegramAPI } from "../utils/telegramAPI";
import { DiscordAPI } from "../utils/discordAPI";
import { HuggingFaceAPI } from "../utils/huggingfaceAPI";
import { WebDAVAPI } from "../utils/webdavAPI";
import { resolveWebDAVConfig } from "../utils/webdavConfig";
import {
setCommonHeaders, setRangeHeaders, handleHeadRequest, getFileContent, isTgChannel,
returnWithCheck, return404, returnBlockImg, isDomainAllowed
@@ -90,6 +92,11 @@ export async function onRequest(context) { // Contents of context object
return await handleHuggingFaceFile(context, imgRecord.metadata, encodedFileName, fileType);
}
/* WebDAV 渠道 */
if (imgRecord.metadata?.Channel === 'WebDAV') {
return await handleWebDAVFile(context, imgRecord.metadata, encodedFileName, fileType);
}
/* 外链渠道 */
if (imgRecord.metadata?.Channel === 'External') {
// 直接重定向到外链
@@ -891,3 +898,74 @@ async function handleHuggingFaceFile(context, metadata, encodedFileName, fileTyp
return new Response(`Error: Failed to fetch from HuggingFace - ${error.message}`, { status: 500 });
}
}
// 处理 WebDAV 文件读取
async function handleWebDAVFile(context, metadata, encodedFileName, fileType) {
const { request, url, Referer } = context;
try {
const filePath = metadata.WebDAVFilePath;
const publicUrl = metadata.WebDAVPublicUrl;
if (!filePath && !publicUrl) {
return new Response('Error: WebDAV file info not found', { status: 500 });
}
const headers = new Headers();
setCommonHeaders(headers, encodedFileName, fileType, Referer, url);
const fetchHeaders = {};
const range = request.headers.get('Range');
if (range) {
fetchHeaders['Range'] = range;
}
let response;
if (publicUrl) {
response = await fetch(publicUrl, {
method: request.method === 'HEAD' ? 'HEAD' : 'GET',
headers: fetchHeaders,
});
} else {
const webdavConfig = await resolveWebDAVConfig(context.env, metadata);
if (!webdavConfig) {
return new Response('Error: WebDAV channel config not found', { status: 500 });
}
const webdavAPI = new WebDAVAPI(webdavConfig);
response = await webdavAPI.getFile(filePath, {
method: request.method === 'HEAD' ? 'HEAD' : 'GET',
headers: fetchHeaders,
});
}
if (!response.ok && response.status !== 206 && response.status !== 304) {
return new Response(`Error: Failed to fetch from WebDAV - ${response.status}`, { status: response.status });
}
if (response.headers.get('Content-Length')) {
headers.set('Content-Length', response.headers.get('Content-Length'));
}
if (response.headers.get('Content-Range')) {
headers.set('Content-Range', response.headers.get('Content-Range'));
}
if (response.headers.get('ETag')) {
headers.set('ETag', response.headers.get('ETag'));
}
if (response.status === 304) {
return new Response(null, { status: 304, headers });
}
if (request.method === 'HEAD') {
return handleHeadRequest(headers, response.headers.get('ETag'));
}
return new Response(response.body, {
status: response.status,
headers
});
} catch (error) {
return new Response(`Error: Failed to fetch from WebDAV - ${error.message}`, { status: 500 });
}
}
+3
View File
@@ -46,6 +46,9 @@ export async function handleChunkMerge(context) {
// 使用会话中的上传渠道,或者从URL参数获取
uploadChannel = url.searchParams.get('uploadChannel') || sessionInfo.uploadChannel || 'telegram';
if (uploadChannel === 'webdav') {
return createResponse('Error: WebDAV channel does not support chunked uploads. Please use non-chunked upload within your Cloudflare request body limit.', { status: 400 });
}
// 获取指定的渠道名称(优先URL参数,其次会话信息)
const channelName = url.searchParams.get('channelName') || sessionInfo.channelName || '';
+6
View File
@@ -33,6 +33,9 @@ export async function initializeChunkedUpload(context) {
// 获取上传渠道
const uploadChannel = url.searchParams.get('uploadChannel') || 'telegram';
if (uploadChannel === 'webdav') {
return createResponse('Error: WebDAV channel does not support chunked uploads. Please use non-chunked upload within your Cloudflare request body limit.', { status: 400 });
}
// 获取指定的渠道名称
const channelName = url.searchParams.get('channelName') || '';
@@ -121,6 +124,9 @@ export async function handleChunkUpload(context) {
// 获取上传渠道
const uploadChannel = url.searchParams.get('uploadChannel') || sessionInfo.uploadChannel || 'telegram';
if (uploadChannel === 'webdav') {
return createResponse('Error: WebDAV channel does not support chunked uploads. Please use non-chunked upload within your Cloudflare request body limit.', { status: 400 });
}
// 获取指定的渠道名称
const channelName = url.searchParams.get('channelName') || sessionInfo.channelName || '';
+100 -1
View File
@@ -10,6 +10,7 @@ import { handleChunkMerge } from "./chunkMerge";
import { TelegramAPI } from "../utils/telegramAPI";
import { DiscordAPI } from "../utils/discordAPI";
import { HuggingFaceAPI } from "../utils/huggingfaceAPI";
import { WebDAVAPI } from "../utils/webdavAPI";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getDatabase } from '../utils/databaseAdapter.js';
@@ -115,6 +116,9 @@ async function processFileUpload(context, formdata = null) {
case 'huggingface':
uploadChannel = 'HuggingFace';
break;
case 'webdav':
uploadChannel = 'WebDAV';
break;
case 'external':
uploadChannel = 'External';
break;
@@ -234,6 +238,14 @@ async function processFileUpload(context, formdata = null) {
} else {
err = await res.text();
}
} else if (uploadChannel === 'WebDAV') {
// ---------------------WebDAV 渠道------------------
const res = await uploadFileToWebDAV(context, fullId, metadata, returnLink);
if (res.status === 200 || !autoRetry) {
return res;
} else {
err = await res.text();
}
} else if (uploadChannel === 'External') {
// --------------------外链渠道----------------------
const res = await uploadFileToExternal(context, fullId, metadata, returnLink);
@@ -829,12 +841,95 @@ async function uploadFileToHuggingFace(context, fullId, metadata, returnLink) {
}
// 上传到 WebDAV
async function uploadFileToWebDAV(context, fullId, metadata, returnLink) {
const { env, waitUntil, uploadConfig, securityConfig, url, formdata, specifiedChannelName } = context;
const db = getDatabase(env);
const webdavSettings = uploadConfig.webdav;
if (!webdavSettings || !webdavSettings.channels || webdavSettings.channels.length === 0) {
return createResponse('Error: No WebDAV channel configured', { status: 400 });
}
const webdavChannels = webdavSettings.channels;
let webdavChannel;
if (specifiedChannelName) {
webdavChannel = webdavChannels.find(ch => ch.name === specifiedChannelName);
}
if (!webdavChannel) {
webdavChannel = webdavSettings.loadBalance?.enabled
? webdavChannels[Math.floor(Math.random() * webdavChannels.length)]
: webdavChannels[0];
}
const baseUrl = webdavChannel?.baseUrl || webdavChannel?.endpoint || webdavChannel?.url;
if (!webdavChannel || !baseUrl) {
return createResponse('Error: WebDAV channel not properly configured', { status: 400 });
}
const file = formdata.get('file');
if (!file) {
return createResponse('Error: No file provided', { status: 400 });
}
try {
const webdavAPI = new WebDAVAPI(webdavChannel);
await webdavAPI.putFile(fullId, file, file.type || metadata.FileType || 'application/octet-stream');
metadata.Channel = "WebDAV";
metadata.ChannelName = webdavChannel.name || "WebDAV_env";
metadata.WebDAVBaseUrl = baseUrl;
metadata.WebDAVFilePath = fullId;
if (webdavChannel.publicUrl) {
metadata.WebDAVPublicBaseUrl = webdavChannel.publicUrl;
metadata.WebDAVPublicUrl = webdavAPI.buildPublicUrl(fullId, webdavChannel.publicUrl);
}
const uploadModerate = securityConfig.upload?.moderate;
if (uploadModerate && uploadModerate.enabled) {
if (metadata.WebDAVPublicUrl) {
metadata.Label = await moderateContent(env, metadata.WebDAVPublicUrl);
} else {
try {
await db.put(fullId, "", { metadata });
} catch {
return createResponse('Error: Failed to write to database', { status: 500 });
}
const moderateUrl = `https://${url.hostname}/file/${fullId}`;
await purgeCDNCache(env, moderateUrl, url);
metadata.Label = await moderateContent(env, moderateUrl);
}
}
try {
await db.put(fullId, "", { metadata });
} catch {
return createResponse('Error: Failed to write to database', { status: 500 });
}
waitUntil(endUpload(context, fullId, metadata));
return createResponse(
JSON.stringify([{ 'src': returnLink }]),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('WebDAV upload error:', error.message);
return createResponse(`Error: WebDAV upload failed - ${error.message}`, { status: 500 });
}
}
// 自动切换渠道重试
async function tryRetry(err, context, uploadChannel, fullId, metadata, fileExt, fileName, fileType, returnLink) {
const { env, url, formdata } = context;
// 渠道列表(Discord 因为有 10MB 限制,放在最后尝试)
const channelList = ['CloudflareR2', 'TelegramNew', 'S3', 'HuggingFace', 'Discord'];
const channelList = ['CloudflareR2', 'TelegramNew', 'S3', 'HuggingFace', 'WebDAV', 'Discord'];
const errMessages = {};
errMessages[uploadChannel] = 'Error: ' + uploadChannel + err;
@@ -849,6 +944,8 @@ async function tryRetry(err, context, uploadChannel, fullId, metadata, fileExt,
retryRes = await uploadFileToS3(context, fullId, metadata, returnLink);
} else if (uploadChannel === 'HuggingFace') {
retryRes = await uploadFileToHuggingFace(context, fullId, metadata, returnLink);
} else if (uploadChannel === 'WebDAV') {
retryRes = await uploadFileToWebDAV(context, fullId, metadata, returnLink);
} else if (uploadChannel === 'Discord') {
retryRes = await uploadFileToDiscord(context, fullId, metadata, returnLink);
}
@@ -872,6 +969,8 @@ async function tryRetry(err, context, uploadChannel, fullId, metadata, fileExt,
res = await uploadFileToS3(context, fullId, metadata, returnLink);
} else if (channelList[i] === 'HuggingFace') {
res = await uploadFileToHuggingFace(context, fullId, metadata, returnLink);
} else if (channelList[i] === 'WebDAV') {
res = await uploadFileToWebDAV(context, fullId, metadata, returnLink);
} else if (channelList[i] === 'Discord') {
res = await uploadFileToDiscord(context, fullId, metadata, returnLink);
}
+10 -7
View File
@@ -1,7 +1,7 @@
import { getUploadConfig } from '../api/manage/sysConfig/upload';
import { getSecurityConfig } from '../api/manage/sysConfig/security';
import { getPageConfig } from '../api/manage/sysConfig/page';
import { getOthersConfig } from '../api/manage/sysConfig/others';
import { getUploadConfig } from '../api/manage/sysConfig/upload.js';
import { getSecurityConfig } from '../api/manage/sysConfig/security.js';
import { getPageConfig } from '../api/manage/sysConfig/page.js';
import { getOthersConfig } from '../api/manage/sysConfig/others.js';
import { getDatabase } from './databaseAdapter.js';
import { getIndexMeta } from './indexManager.js';
@@ -63,12 +63,14 @@ export async function fetchUploadConfig(env, context = null) {
settings.s3.channels = settings.s3.channels.filter((channel) => channel.enabled);
settings.discord.channels = settings.discord.channels.filter((channel) => channel.enabled);
settings.huggingface.channels = settings.huggingface.channels.filter((channel) => channel.enabled);
settings.webdav.channels = settings.webdav.channels.filter((channel) => channel.enabled);
// 根据容量限制过滤渠道(仅 R2 和 S3
// 根据容量限制过滤渠道(可用于 R2、S3、WebDAV
// 需要 context 来调用 getIndexMeta
if (context) {
settings.cfr2.channels = await filterChannelsByQuota(context, settings.cfr2.channels);
settings.s3.channels = await filterChannelsByQuota(context, settings.s3.channels);
settings.webdav.channels = await filterChannelsByQuota(context, settings.webdav.channels);
}
return settings;
@@ -80,7 +82,8 @@ export async function fetchUploadConfig(env, context = null) {
cfr2: { channels: [] },
s3: { channels: [] },
discord: { channels: [] },
huggingface: { channels: [] }
huggingface: { channels: [] },
webdav: { channels: [] }
};
}
}
@@ -130,4 +133,4 @@ export async function fetchOthersConfig(env) {
telemetry: { enabled: false }
};
}
}
}
+240
View File
@@ -0,0 +1,240 @@
/**
* WebDAV API helper
*
* Uses only Fetch/Web APIs so it works in Cloudflare Pages Functions,
* Cloudflare Workers, and the local Node-based test/runtime paths.
*/
export class WebDAVAPI {
constructor(config = {}) {
const baseUrl = config.baseUrl || config.endpoint || config.url;
if (!baseUrl) {
throw new Error('WebDAV baseUrl is required');
}
this.baseUrl = normalizeBaseUrl(baseUrl);
this.username = config.username || '';
this.password = config.password || '';
this.headers = normalizeHeaders(config.headers || config.customHeaders || {});
this.createDirectory = config.createDirectory !== false;
}
buildObjectUrl(path) {
return buildWebDAVUrl(this.baseUrl, path);
}
buildPublicUrl(path, publicUrl = '') {
if (!publicUrl) return '';
return buildWebDAVUrl(normalizeBaseUrl(publicUrl), path);
}
getRequestHeaders(extraHeaders = {}) {
const headers = new Headers(this.headers);
if ((this.username || this.password) && !headers.has('Authorization')) {
headers.set('Authorization', `Basic ${base64EncodeUtf8(`${this.username}:${this.password}`)}`);
}
for (const [key, value] of Object.entries(extraHeaders || {})) {
if (value !== undefined && value !== null && value !== '') {
headers.set(key, value);
}
}
return headers;
}
async ensureDirectory(path) {
if (!this.createDirectory) return;
const dirParts = getDirectoryParts(path);
if (dirParts.length === 0) return;
let currentPath = '';
for (const part of dirParts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const response = await fetch(this.buildObjectUrl(currentPath), {
method: 'MKCOL',
headers: this.getRequestHeaders(),
redirect: 'manual',
});
// 405 commonly means the collection already exists. Some servers return 200/204.
if (![200, 201, 204, 405].includes(response.status)) {
throw new Error(`WebDAV MKCOL failed for ${currentPath}: ${response.status} ${response.statusText}`);
}
}
}
async putFile(path, body, contentType = '') {
await this.ensureDirectory(path);
const headers = this.getRequestHeaders(contentType ? { 'Content-Type': contentType } : {});
const response = await fetch(this.buildObjectUrl(path), {
method: 'PUT',
headers,
body,
redirect: 'manual',
});
if (!isSuccessStatus(response.status)) {
const detail = await safeReadResponseText(response);
throw new Error(`WebDAV PUT failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`);
}
return response;
}
async getFile(path, options = {}) {
const response = await fetch(this.buildObjectUrl(path), {
method: options.method || 'GET',
headers: this.getRequestHeaders(options.headers || {}),
redirect: 'manual',
});
if (!isSuccessStatus(response.status) && response.status !== 304) {
const detail = await safeReadResponseText(response);
throw new Error(`WebDAV ${options.method || 'GET'} failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`);
}
return response;
}
async moveFile(oldPath, newPath, overwrite = true) {
await this.ensureDirectory(newPath);
const response = await fetch(this.buildObjectUrl(oldPath), {
method: 'MOVE',
headers: this.getRequestHeaders({
Destination: this.buildObjectUrl(newPath),
Overwrite: overwrite ? 'T' : 'F',
}),
redirect: 'manual',
});
if (!isSuccessStatus(response.status)) {
const detail = await safeReadResponseText(response);
throw new Error(`WebDAV MOVE failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`);
}
return true;
}
async deleteFile(path) {
const response = await fetch(this.buildObjectUrl(path), {
method: 'DELETE',
headers: this.getRequestHeaders(),
redirect: 'manual',
});
// DELETE is idempotent for app semantics; a missing remote object should not block DB cleanup.
if (response.status === 404) return true;
if (!isSuccessStatus(response.status)) {
const detail = await safeReadResponseText(response);
throw new Error(`WebDAV DELETE failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`);
}
return true;
}
}
export function normalizeBaseUrl(baseUrl) {
const normalized = String(baseUrl || '').trim();
if (!normalized) {
throw new Error('WebDAV baseUrl is required');
}
const url = new URL(normalized);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('WebDAV baseUrl must use http or https');
}
if (!url.pathname.endsWith('/')) {
url.pathname = `${url.pathname}/`;
}
return url.toString();
}
export function buildWebDAVUrl(baseUrl, path) {
const cleanPath = String(path || '')
.replace(/^\/+/, '')
.split('/')
.filter(Boolean)
.map(encodeURIComponent)
.join('/');
return new URL(cleanPath, normalizeBaseUrl(baseUrl)).toString();
}
export function normalizeWebDAVHeaders(headers) {
return normalizeHeaders(headers);
}
function getDirectoryParts(path) {
const parts = String(path || '').replace(/^\/+/, '').split('/').filter(Boolean);
parts.pop();
return parts;
}
function normalizeHeaders(headers) {
if (!headers) return {};
if (typeof headers === 'string') {
try {
const parsed = JSON.parse(headers);
return normalizeHeaders(parsed);
} catch {
return {};
}
}
if (headers instanceof Headers) {
const result = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
if (typeof headers === 'object' && !Array.isArray(headers)) {
const result = {};
for (const [key, value] of Object.entries(headers)) {
if (value !== undefined && value !== null && value !== '') {
result[key] = String(value);
}
}
return result;
}
return {};
}
function isSuccessStatus(status) {
return status >= 200 && status < 300;
}
async function safeReadResponseText(response) {
try {
const text = await response.text();
return text.slice(0, 500);
} catch {
return '';
}
}
function base64EncodeUtf8(value) {
const bytes = new TextEncoder().encode(value);
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
if (typeof btoa === 'function') {
return btoa(binary);
}
// Node-based local tests/runtimes.
return Buffer.from(value, 'utf8').toString('base64');
}
+50
View File
@@ -0,0 +1,50 @@
import { getUploadConfig } from '../api/manage/sysConfig/upload.js';
import { getDatabase } from './databaseAdapter.js';
import { normalizeWebDAVHeaders } from './webdavAPI.js';
export async function resolveWebDAVConfig(env, metadata = {}) {
const channelName = metadata.ChannelName;
const metadataBaseUrl = metadata.WebDAVBaseUrl;
try {
const db = getDatabase(env);
const uploadConfig = await getUploadConfig(db, env);
const channels = uploadConfig.webdav?.channels || [];
const channel = channels.find((item) => item.name === channelName)
|| channels.find((item) => getWebDAVBaseUrl(item) === metadataBaseUrl);
if (channel) {
return normalizeWebDAVConfig(channel);
}
} catch (error) {
console.error('Failed to resolve WebDAV channel config:', error);
}
return normalizeWebDAVConfig({
baseUrl: metadataBaseUrl,
username: metadata.WebDAVUsername || '',
password: metadata.WebDAVPassword || '',
headers: metadata.WebDAVHeaders || {},
createDirectory: metadata.WebDAVCreateDirectory !== false,
publicUrl: metadata.WebDAVPublicBaseUrl || '',
});
}
function normalizeWebDAVConfig(config = {}) {
const baseUrl = getWebDAVBaseUrl(config);
if (!baseUrl) return null;
return {
baseUrl,
username: config.username || '',
password: config.password || '',
headers: normalizeWebDAVHeaders(config.headers || config.customHeaders || {}),
createDirectory: config.createDirectory !== false,
publicUrl: config.publicUrl || '',
};
}
function getWebDAVBaseUrl(config = {}) {
return config.baseUrl || config.endpoint || config.url || '';
}
+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import { WebDAVAPI, buildWebDAVUrl, normalizeBaseUrl } from '../functions/utils/webdavAPI.js';
describe('WebDAVAPI', () => {
let originalFetch;
let calls;
beforeEach(() => {
originalFetch = globalThis.fetch;
calls = [];
globalThis.fetch = async (url, init = {}) => {
calls.push({ url: String(url), init });
return new Response('ok', { status: 201, statusText: 'Created' });
};
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('normalizes base URLs and encodes object paths segment-by-segment', () => {
assert.equal(normalizeBaseUrl('https://dav.example.com/root'), 'https://dav.example.com/root/');
assert.equal(
buildWebDAVUrl('https://dav.example.com/root/', '相册/a b.png'),
'https://dav.example.com/root/%E7%9B%B8%E5%86%8C/a%20b.png'
);
});
it('creates parent collections and uploads with basic auth', async () => {
const api = new WebDAVAPI({
baseUrl: 'https://dav.example.com/root',
username: 'user',
password: 'pass',
});
await api.putFile('album/photo.png', new Blob(['hello'], { type: 'image/png' }), 'image/png');
assert.equal(calls.length, 2);
assert.equal(calls[0].url, 'https://dav.example.com/root/album');
assert.equal(calls[0].init.method, 'MKCOL');
assert.equal(calls[1].url, 'https://dav.example.com/root/album/photo.png');
assert.equal(calls[1].init.method, 'PUT');
assert.equal(calls[1].init.headers.get('Authorization'), 'Basic dXNlcjpwYXNz');
assert.equal(calls[1].init.headers.get('Content-Type'), 'image/png');
});
it('forwards range headers when reading files', async () => {
globalThis.fetch = async (url, init = {}) => {
calls.push({ url: String(url), init });
return new Response('partial', { status: 206, headers: { 'Content-Range': 'bytes 0-2/7' } });
};
const api = new WebDAVAPI({ baseUrl: 'https://dav.example.com/root' });
const res = await api.getFile('photo.png', { headers: { Range: 'bytes=0-2' } });
assert.equal(res.status, 206);
assert.equal(calls[0].init.method, 'GET');
assert.equal(calls[0].init.headers.get('Range'), 'bytes=0-2');
});
it('treats delete 404 as idempotent success', async () => {
globalThis.fetch = async (url, init = {}) => {
calls.push({ url: String(url), init });
return new Response('missing', { status: 404 });
};
const api = new WebDAVAPI({ baseUrl: 'https://dav.example.com/root' });
assert.equal(await api.deleteFile('missing.png'), true);
assert.equal(calls[0].init.method, 'DELETE');
});
it('uses WebDAV MOVE with an absolute destination URL', async () => {
const api = new WebDAVAPI({ baseUrl: 'https://dav.example.com/root' });
await api.moveFile('old/photo.png', 'new/photo.png');
assert.equal(calls[0].init.method, 'MKCOL');
assert.equal(calls[1].init.method, 'MOVE');
assert.equal(calls[1].init.headers.get('Destination'), 'https://dav.example.com/root/new/photo.png');
assert.equal(calls[1].init.headers.get('Overwrite'), 'T');
});
it('surfaces auth failures from providers', async () => {
globalThis.fetch = async () => new Response('auth failed', { status: 401, statusText: 'Unauthorized' });
const api = new WebDAVAPI({ baseUrl: 'https://dav.example.com/root' });
await assert.rejects(
() => api.getFile('secret.png'),
/WebDAV GET failed: 401 Unauthorized - auth failed/
);
});
});
+99
View File
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { getUploadConfig } from '../functions/api/manage/sysConfig/upload.js';
import { fetchUploadConfig } from '../functions/utils/sysConfig.js';
import { resolveWebDAVConfig } from '../functions/utils/webdavConfig.js';
import { onRequest as channelsOnRequest } from '../functions/api/channels.js';
function makeKV(initial = {}) {
const store = new Map(Object.entries(initial));
return {
async get(key) { return store.get(key) ?? null; },
async put(key, value) { store.set(key, value); },
async delete(key) { store.delete(key); },
async getWithMetadata(key) { return { value: store.get(key) ?? null, metadata: null }; },
async list() { return { keys: [] }; },
};
}
describe('WebDAV upload config', () => {
it('exposes an environment-backed fixed WebDAV channel', async () => {
const config = await getUploadConfig(makeKV(), {
WEBDAV_BASE_URL: 'https://dav.example.com/root/',
WEBDAV_USERNAME: 'alice',
WEBDAV_PASSWORD: 'secret',
WEBDAV_PUBLIC_URL: 'https://cdn.example.com/root/',
WEBDAV_HEADERS: '{"X-Test":"1"}',
WEBDAV_CREATE_DIRECTORY: 'false',
});
assert.equal(config.webdav.channels.length, 1);
assert.equal(config.webdav.channels[0].name, 'WebDAV_env');
assert.equal(config.webdav.channels[0].type, 'webdav');
assert.equal(config.webdav.channels[0].baseUrl, 'https://dav.example.com/root/');
assert.equal(config.webdav.channels[0].headers['X-Test'], '1');
assert.equal(config.webdav.channels[0].createDirectory, false);
assert.equal(config.webdav.channels[0].fixed, true);
});
it('filters disabled WebDAV channels from runtime upload config', async () => {
const uploadSettings = JSON.stringify({
webdav: {
loadBalance: { enabled: false },
channels: [
{ name: 'enabled-dav', type: 'webdav', baseUrl: 'https://dav.example.com/', enabled: true },
{ name: 'disabled-dav', type: 'webdav', baseUrl: 'https://dav2.example.com/', enabled: false },
],
},
});
const env = { img_url: makeKV({ 'manage@sysConfig@upload': uploadSettings }) };
const config = await fetchUploadConfig(env);
assert.deepEqual(config.webdav.channels.map(ch => ch.name), ['enabled-dav']);
});
it('includes WebDAV channels in /api/channels output', async () => {
const uploadSettings = JSON.stringify({
webdav: {
loadBalance: { enabled: false },
channels: [{ name: 'dav-main', type: 'webdav', baseUrl: 'https://dav.example.com/', enabled: true }],
},
});
const env = { img_url: makeKV({ 'manage@sysConfig@upload': uploadSettings }) };
const request = new Request('https://img.example.com/api/channels');
const response = await channelsOnRequest({ request, env });
const body = await response.json();
assert.equal(response.status, 200);
assert.deepEqual(body.webdav, [{ name: 'dav-main', type: 'WebDAV' }]);
});
it('resolves WebDAV secrets from channel config by ChannelName', async () => {
const uploadSettings = JSON.stringify({
webdav: {
loadBalance: { enabled: false },
channels: [{
name: 'dav-main',
type: 'webdav',
baseUrl: 'https://dav.example.com/root/',
username: 'alice',
password: 'secret',
headers: { 'X-Test': '1' },
enabled: true,
}],
},
});
const env = { img_url: makeKV({ 'manage@sysConfig@upload': uploadSettings }) };
const config = await resolveWebDAVConfig(env, {
ChannelName: 'dav-main',
WebDAVBaseUrl: 'https://dav.example.com/root/',
WebDAVFilePath: 'a.png',
});
assert.equal(config.username, 'alice');
assert.equal(config.password, 'secret');
assert.deepEqual(config.headers, { 'X-Test': '1' });
});
});