mirror of
https://github.com/ZSCGR/CloudFlare-ImgBed.git
synced 2026-08-13 04:03:42 +08:00
Merge pull request #536 from htazq/feature/webdav-storage-channel
Enable third-party WebDAV storage without new runtime dependencies
This commit is contained in:
@@ -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
@@ -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)项目。
|
||||
|
||||
@@ -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.
@@ -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'
|
||||
}))
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 || '';
|
||||
}
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user