Enable third-party WebDAV storage without new runtime dependencies

Cloudflare Workers and Pages already provide the Fetch/Web APIs needed for WebDAV verbs, so the storage integration uses a small local helper instead of adding a Node-oriented WebDAV client. The channel now participates in upload, read, delete, move, rename, channel listing, and runtime config, with chunked uploads explicitly guarded because WebDAV has no portable server-side compose primitive.

Constraint: Preserve Pages Functions and generated Worker deployment paths
Constraint: No new npm dependency for WebDAV client behavior
Rejected: Add a WebDAV npm client | likely Node API/compatibility and package-lock churn
Rejected: Treat WebDAV as External URL only | not a complete storage channel lifecycle
Confidence: high
Scope-risk: moderate
Directive: WebDAV here is third-party storage; keep it distinct from the built-in /dav server settings
Tested: npm test; node worker/generate-routes.js; npx wrangler deploy --dry-run --config worker/wrangler.toml; git diff --cached --check
Not-tested: Live third-party WebDAV provider credentials; Digest-only WebDAV authentication
This commit is contained in:
htazq committed 2026-04-28 00:05:12 +08:00
1 parent 322cf49532
commit 9c7a2b4336
19 files changed
+848 -14

No files matched your search

+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"
+1 -1
View File
@@ -1,2 +1,2 @@
(self["webpackChunksanyue_imghub"]=self["webpackChunksanyue_imghub"]||[]).push([[577],{213:function(e,t,o){"use strict";o(85331),o(19104)},2893:function(e,t,o){"use strict";o(85331)},4885:function(e,t,o){"use strict";o(85331)},10456:function(e,t,o){"use strict";o(85331)},11393:function(e,t,o){o(16573),o(78100),o(77936),o(79577),o(41549),o(49797),o(49631),o(35623),o(64979),o(14603),o(47566),o(98721),function(t,o){e.exports=o()}(0,function(){return function(e){var t={};function o(s){if(t[s])return t[s].exports;var l=t[s]={i:s,l:!1,exports:{}};return e[s].call(l.exports,l,l.exports,o),l.l=!0,l.exports}return o.m=e,o.c=t,o.d=function(e,t,s){o.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:s})},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.t=function(e,t){if(1&t&&(e=o(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var s=Object.create(null);if(o.r(s),Object.defineProperty(s,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var l in e)o.d(s,l,function(t){return e[t]}.bind(null,l));return s},o.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(t,"a",t),t},o.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},o.p="",o(o.s=0)}([function(e,t,o){"use strict";var s;function l(e){return["image/png","image/jpeg","image/gif"].some(t=>t===e)}o.r(t),o.d(t,"canvastoDataURL",function(){return a}),o.d(t,"canvastoFile",function(){return n}),o.d(t,"dataURLtoFile",function(){return d}),o.d(t,"dataURLtoImage",function(){return c}),o.d(t,"downloadFile",function(){return u}),o.d(t,"filetoDataURL",function(){return p}),o.d(t,"imagetoCanvas",function(){return m}),o.d(t,"urltoBlob",function(){return g}),o.d(t,"urltoImage",function(){return f}),o.d(t,"compress",function(){return b}),o.d(t,"compressAccurately",function(){return v}),o.d(t,"EImageType",function(){return s}),function(e){e.PNG="image/png",e.JPEG="image/jpeg",e.GIF="image/gif"}(s||(s={}));var i=function(e,t,o,s){return new(o||(o=Promise))(function(l,i){function a(e){try{r(s.next(e))}catch(e){i(e)}}function n(e){try{r(s.throw(e))}catch(e){i(e)}}function r(e){var t;e.done?l(e.value):(t=e.value,t instanceof o?t:new o(function(e){e(t)})).then(a,n)}r((s=s.apply(e,t||[])).next())})};function a(e,t=.92,o=s.JPEG){return i(this,void 0,void 0,function*(){return l(o)||(o=s.JPEG),e.toDataURL(o,t)})}function n(e,t=.92,o=s.JPEG){return new Promise(s=>e.toBlob(e=>s(e),o,t))}var r=function(e,t,o,s){return new(o||(o=Promise))(function(l,i){function a(e){try{r(s.next(e))}catch(e){i(e)}}function n(e){try{r(s.throw(e))}catch(e){i(e)}}function r(e){var t;e.done?l(e.value):(t=e.value,t instanceof o?t:new o(function(e){e(t)})).then(a,n)}r((s=s.apply(e,t||[])).next())})};function d(e,t){return r(this,void 0,void 0,function*(){const o=e.split(",");let s=o[0].match(/:(.*?);/)[1];const i=atob(o[1]);let a=i.length;const n=new Uint8Array(a);for(;a--;)n[a]=i.charCodeAt(a);return l(t)&&(s=t),new Blob([n],{type:s})})}function c(e){return new Promise((t,o)=>{const s=new Image;s.onload=()=>t(s),s.onerror=()=>o(new Error("dataURLtoImage(): dataURL is illegal")),s.src=e})}function u(e,t){const o=document.createElement("a");o.href=window.URL.createObjectURL(e),o.download=t||Date.now().toString(36),document.body.appendChild(o);const s=document.createEvent("MouseEvents");s.initEvent("click",!1,!1),o.dispatchEvent(s),document.body.removeChild(o)}function p(e){return new Promise(t=>{const o=new FileReader;o.onloadend=e=>t(e.target.result),o.readAsDataURL(e)})}var h=function(e,t,o,s){return new(o||(o=Promise))(function(l,i){function a(e){try{r(s.next(e))}catch(e){i(e)}}function n(e){try{r(s.throw(e))}catch(e){i(e)}}function r(e){var t;e.done?l(e.value):(t=e.value,t instanceof o?t:new o(function(e){e(t)})).then(a,n)}r((s=s.apply(e,t||[])).next())})};function m(e,t={}){return h(this,void 0,void 0,function*(){const o=Object.assign({},t),s=document.createElement("canvas"),l=s.getContext("2d");let i,a;for(const e in o)Object.prototype.hasOwnProperty.call(o,e)&&(o[e]=Number(o[e]));if(o.scale){const t=o.scale>0&&o.scale<10?o.scale:1;a=e.width*t,i=e.height*t}else a=o.width||o.height*e.width/e.height||e.width,i=o.height||o.width*e.height/e.width||e.height;switch([5,6,7,8].some(e=>e===o.orientation)?(s.height=a,s.width=i):(s.height=i,s.width=a),o.orientation){case 3:l.rotate(180*Math.PI/180),l.drawImage(e,-s.width,-s.height,s.width,s.height);break;case 6:l.rotate(90*Math.PI/180),l.drawImage(e,0,-s.width,s.height,s.width);break;case 8:l.rotate(270*Math.PI/180),l.drawImage(e,-s.height,0,s.height,s.width);break;case 2:l.translate(s.width,0),l.scale(-1,1),l.drawImage(e,0,0,s.width,s.height);break;case 4:l.translate(s.width,0),l.scale(-1,1),l.rotate(180*Math.PI/180),l.drawImage(e,-s.width,-s.height,s.width,s.height);break;case 5:l.translate(s.width,0),l.scale(-1,1),l.rotate(90*Math.PI/180),l.drawImage(eLine truncated
(self["webpackChunksanyue_imghub"]=self["webpackChunksanyue_imghub"]||[]).push([[577],{213:function(e,t,o){"use strict";o(85331),o(19104)},2893:function(e,t,o){"use strict";o(85331)},4885:function(e,t,o){"use strict";o(85331)},10456:function(e,t,o){"use strict";o(85331)},11393:function(e,t,o){o(16573),o(78100),o(77936),o(79577),o(41549),o(49797),o(49631),o(35623),o(64979),o(14603),o(47566),o(98721),function(t,o){e.exports=o()}(0,function(){return function(e){var t={};function o(s){if(t[s])return t[s].exports;var l=t[s]={i:s,l:!1,exports:{}};return e[s].call(l.exports,l,l.exports,o),l.l=!0,l.exports}return o.m=e,o.c=t,o.d=function(e,t,s){o.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:s})},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.t=function(e,t){if(1&t&&(e=o(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var s=Object.create(null);if(o.r(s),Object.defineProperty(s,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var l in e)o.d(s,l,function(t){return e[t]}.bind(null,l));return s},o.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(t,"a",t),t},o.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},o.p="",o(o.s=0)}([function(e,t,o){"use strict";var s;function l(e){return["image/png","image/jpeg","image/gif"].some(t=>t===e)}o.r(t),o.d(t,"canvastoDataURL",function(){return a}),o.d(t,"canvastoFile",function(){return n}),o.d(t,"dataURLtoFile",function(){return d}),o.d(t,"dataURLtoImage",function(){return c}),o.d(t,"downloadFile",function(){return u}),o.d(t,"filetoDataURL",function(){return p}),o.d(t,"imagetoCanvas",function(){return m}),o.d(t,"urltoBlob",function(){return g}),o.d(t,"urltoImage",function(){return f}),o.d(t,"compress",function(){return b}),o.d(t,"compressAccurately",function(){return v}),o.d(t,"EImageType",function(){return s}),function(e){e.PNG="image/png",e.JPEG="image/jpeg",e.GIF="image/gif"}(s||(s={}));var i=function(e,t,o,s){return new(o||(o=Promise))(function(l,i){function a(e){try{r(s.next(e))}catch(e){i(e)}}function n(e){try{r(s.throw(e))}catch(e){i(e)}}function r(e){var t;e.done?l(e.value):(t=e.value,t instanceof o?t:new o(function(e){e(t)})).then(a,n)}r((s=s.apply(e,t||[])).next())})};function a(e,t=.92,o=s.JPEG){return i(this,void 0,void 0,function*(){return l(o)||(o=s.JPEG),e.toDataURL(o,t)})}function n(e,t=.92,o=s.JPEG){return new Promise(s=>e.toBlob(e=>s(e),o,t))}var r=function(e,t,o,s){return new(o||(o=Promise))(function(l,i){function a(e){try{r(s.next(e))}catch(e){i(e)}}function n(e){try{r(s.throw(e))}catch(e){i(e)}}function r(e){var t;e.done?l(e.value):(t=e.value,t instanceof o?t:new o(function(e){e(t)})).then(a,n)}r((s=s.apply(e,t||[])).next())})};function d(e,t){return r(this,void 0,void 0,function*(){const o=e.split(",");let s=o[0].match(/:(.*?);/)[1];const i=atob(o[1]);let a=i.length;const n=new Uint8Array(a);for(;a--;)n[a]=i.charCodeAt(a);return l(t)&&(s=t),new Blob([n],{type:s})})}function c(e){return new Promise((t,o)=>{const s=new Image;s.onload=()=>t(s),s.onerror=()=>o(new Error("dataURLtoImage(): dataURL is illegal")),s.src=e})}function u(e,t){const o=document.createElement("a");o.href=window.URL.createObjectURL(e),o.download=t||Date.now().toString(36),document.body.appendChild(o);const s=document.createEvent("MouseEvents");s.initEvent("click",!1,!1),o.dispatchEvent(s),document.body.removeChild(o)}function p(e){return new Promise(t=>{const o=new FileReader;o.onloadend=e=>t(e.target.result),o.readAsDataURL(e)})}var h=function(e,t,o,s){return new(o||(o=Promise))(function(l,i){function a(e){try{r(s.next(e))}catch(e){i(e)}}function n(e){try{r(s.throw(e))}catch(e){i(e)}}function r(e){var t;e.done?l(e.value):(t=e.value,t instanceof o?t:new o(function(e){e(t)})).then(a,n)}r((s=s.apply(e,t||[])).next())})};function m(e,t={}){return h(this,void 0,void 0,function*(){const o=Object.assign({},t),s=document.createElement("canvas"),l=s.getContext("2d");let i,a;for(const e in o)Object.prototype.hasOwnProperty.call(o,e)&&(o[e]=Number(o[e]));if(o.scale){const t=o.scale>0&&o.scale<10?o.scale:1;a=e.width*t,i=e.height*t}else a=o.width||o.height*e.width/e.height||e.width,i=o.height||o.width*e.height/e.width||e.height;switch([5,6,7,8].some(e=>e===o.orientation)?(s.height=a,s.width=i):(s.height=i,s.width=a),o.orientation){case 3:l.rotate(180*Math.PI/180),l.drawImage(e,-s.width,-s.height,s.width,s.height);break;case 6:l.rotate(90*Math.PI/180),l.drawImage(e,0,-s.width,s.height,s.width);break;case 8:l.rotate(270*Math.PI/180),l.drawImage(e,-s.height,0,s.height,s.width);break;case 2:l.translate(s.width,0),l.scale(-1,1),l.drawImage(e,0,0,s.width,s.height);break;case 4:l.translate(s.width,0),l.scale(-1,1),l.rotate(180*Math.PI/180),l.drawImage(e,-s.width,-s.height,s.width,s.height);break;case 5:l.translate(s.width,0),l.scale(-1,1),l.rotate(90*Math.PI/180),l.drawImage(eLine truncated
//# sourceMappingURL=577.2c9dad30.js.map
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,7 @@ 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';
// CORS 跨域响应头
const corsHeaders = {
@@ -156,6 +157,11 @@ async function deleteFile(env, fileId, cdnUrl, url) {
await deleteHuggingFaceFile(img);
}
// WebDAV 渠道的图片,需要删除 WebDAV 中对应的文件
if (img.metadata?.Channel === 'WebDAV') {
await deleteWebDAVFile(img);
}
// 删除数据库中的记录
// 注意:容量统计现在由索引自动维护,删除文件后索引更新时会自动重新计算
await db.delete(fileId);
@@ -251,3 +257,29 @@ async function deleteHuggingFaceFile(img) {
return false;
}
}
// 删除 WebDAV 渠道的图片
async function deleteWebDAVFile(img) {
const baseUrl = img.metadata?.WebDAVBaseUrl;
const filePath = img.metadata?.WebDAVFilePath;
if (!baseUrl || !filePath) {
console.warn('WebDAV file missing required metadata for deletion');
return false;
}
try {
const webdavAPI = new WebDAVAPI({
baseUrl,
username: img.metadata?.WebDAVUsername || '',
password: img.metadata?.WebDAVPassword || '',
headers: img.metadata?.WebDAVHeaders || {},
createDirectory: img.metadata?.WebDAVCreateDirectory !== false,
});
return await webdavAPI.deleteFile(filePath);
} catch (error) {
console.error("WebDAV Delete Failed:", error);
return false;
}
}
+48 -1
View File
@@ -3,6 +3,7 @@ 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";
export async function onRequest(context) {
const { request, env, params, waitUntil } = context;
@@ -161,6 +162,27 @@ async function moveFile(env, fileId, newFileId, cdnUrl, url) {
}
}
// WebDAV 渠道的图片,需要移动 WebDAV 中对应的文件
if (img.metadata?.Channel === 'WebDAV') {
const { success, error } = await moveWebDAVFile(img, newFileId);
if (!success) {
throw new Error(error || 'WebDAV Move Failed');
}
img.metadata.WebDAVFilePath = newFileId;
if (img.metadata.WebDAVPublicBaseUrl || img.metadata.WebDAVPublicUrl) {
const webdavAPI = new WebDAVAPI({
baseUrl: img.metadata.WebDAVBaseUrl,
username: img.metadata.WebDAVUsername || '',
password: img.metadata.WebDAVPassword || '',
headers: img.metadata.WebDAVHeaders || {},
createDirectory: img.metadata.WebDAVCreateDirectory !== false,
});
const publicBaseUrl = img.metadata.WebDAVPublicBaseUrl
|| img.metadata.WebDAVPublicUrl.slice(0, img.metadata.WebDAVPublicUrl.length - fileId.split('/').map(encodeURIComponent).join('/').length);
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 +248,29 @@ async function moveS3File(img, newFileId) {
console.error("S3 Move Failed:", error);
return { success: false, error: error.message };
}
}
}
// 移动 WebDAV 渠道的图片
async function moveWebDAVFile(img, newFileId) {
const baseUrl = img.metadata?.WebDAVBaseUrl;
const oldPath = img.metadata?.WebDAVFilePath;
if (!baseUrl || !oldPath) {
return { success: false, error: 'WebDAV file missing required metadata for move' };
}
try {
const webdavAPI = new WebDAVAPI({
baseUrl,
username: img.metadata?.WebDAVUsername || '',
password: img.metadata?.WebDAVPassword || '',
headers: img.metadata?.WebDAVHeaders || {},
createDirectory: img.metadata?.WebDAVCreateDirectory !== false,
});
await webdavAPI.moveFile(oldPath, newFileId, true);
return { success: true, newKey: newFileId };
} catch (error) {
console.error("WebDAV Move Failed:", error);
return { success: false, error: error.message };
}
}
+47
View File
@@ -3,6 +3,7 @@ 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";
// CORS 跨域响应头
const corsHeaders = {
@@ -138,6 +139,27 @@ export async function onRequest(context) {
}
}
// WebDAV 渠道的图片,需要移动 WebDAV 中对应的文件
if (metadata?.Channel === 'WebDAV') {
const { success, error } = await moveWebDAVFile(fileData, newFileId);
if (!success) {
throw new Error(error || 'WebDAV Move Failed');
}
metadata.WebDAVFilePath = newFileId;
if (metadata.WebDAVPublicBaseUrl || metadata.WebDAVPublicUrl) {
const webdavAPI = new WebDAVAPI({
baseUrl: metadata.WebDAVBaseUrl,
username: metadata.WebDAVUsername || '',
password: metadata.WebDAVPassword || '',
headers: metadata.WebDAVHeaders || {},
createDirectory: metadata.WebDAVCreateDirectory !== false,
});
const publicBaseUrl = metadata.WebDAVPublicBaseUrl
|| metadata.WebDAVPublicUrl.slice(0, metadata.WebDAVPublicUrl.length - fileId.split('/').map(encodeURIComponent).join('/').length);
metadata.WebDAVPublicUrl = webdavAPI.buildPublicUrl(newFileId, publicBaseUrl);
}
}
// 旧版 Telegram 渠道和 Telegraph 渠道不支持重命名
if (metadata?.Channel === 'Telegram' || metadata?.Channel === undefined) {
return new Response(JSON.stringify({
@@ -228,3 +250,28 @@ async function moveS3File(img, newFileId) {
return { success: false, error: error.message };
}
}
// 移动 WebDAV 渠道的图片
async function moveWebDAVFile(img, newFileId) {
const baseUrl = img.metadata?.WebDAVBaseUrl;
const oldPath = img.metadata?.WebDAVFilePath;
if (!baseUrl || !oldPath) {
return { success: false, error: 'WebDAV file missing required metadata for move' };
}
try {
const webdavAPI = new WebDAVAPI({
baseUrl,
username: img.metadata?.WebDAVUsername || '',
password: img.metadata?.WebDAVPassword || '',
headers: img.metadata?.WebDAVHeaders || {},
createDirectory: img.metadata?.WebDAVCreateDirectory !== false,
});
await webdavAPI.moveFile(oldPath, newFileId, true);
return { success: true, newKey: newFileId };
} 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,7 @@ 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 {
setCommonHeaders, setRangeHeaders, handleHeadRequest, getFileContent, isTgChannel,
returnWithCheck, return404, returnBlockImg, isDomainAllowed
@@ -90,6 +91,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 +897,75 @@ 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 webdavAPI = new WebDAVAPI({
baseUrl: metadata.WebDAVBaseUrl,
username: metadata.WebDAVUsername || '',
password: metadata.WebDAVPassword || '',
headers: metadata.WebDAVHeaders || {},
createDirectory: metadata.WebDAVCreateDirectory !== false,
});
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 || '';
+106 -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,101 @@ 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;
metadata.WebDAVUsername = webdavChannel.username || '';
metadata.WebDAVPassword = webdavChannel.password || '';
metadata.WebDAVCreateDirectory = webdavChannel.createDirectory !== false;
if (webdavChannel.headers || webdavChannel.customHeaders) {
metadata.WebDAVHeaders = webdavChannel.headers || webdavChannel.customHeaders;
}
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 +950,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 +975,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');
}
+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/
);
});
});
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { getUploadConfig } from '../functions/api/manage/sysConfig/upload.js';
import { fetchUploadConfig } from '../functions/utils/sysConfig.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' }]);
});
});