Merge pull request #299 from lengsukq/main

新增webdav支持
This commit is contained in:
叁月柒
2025-08-28 11:16:37 +08:00
committed by GitHub
3 changed files with 470 additions and 2 deletions

105
README.md
View File

@@ -116,7 +116,110 @@
</details>
# 4. Tips
# 4. WebDAV Bridge 桥接服务
本项目提供了一个强大的 **WebDAV Bridge Cloudflare Worker**,让您可以通过标准的 WebDAV 协议访问和管理托管的文件。
## 4.1 功能特性
- 🔒 **身份验证**:支持基于用户名密码的 Basic Auth 认证
- 📁 **目录浏览**:完整的目录结构展示,支持 HTML 页面和 WebDAV 客户端
- 📤 **文件上传**:通过 PUT 方法上传文件到指定目录
- 🗑️ **文件删除**:支持删除单个文件或整个文件夹
- 📥 **文件下载**:直接下载文件,自动代理到上游存储
- 🌐 **跨域支持**:内置 CORS 支持,确保 Web 客户端正常访问
## 4.2 支持的 WebDAV 方法
| 方法 | 功能 | 说明 |
|------|------|------|
| `PROPFIND` | 列出目录内容 | 获取文件和文件夹列表,支持 WebDAV 客户端 |
| `GET` | 下载文件/浏览目录 | 文件下载或 HTML 目录浏览页面 |
| `PUT` | 上传文件 | 上传文件到指定路径和文件夹 |
| `DELETE` | 删除文件/文件夹 | 支持删除单个文件或整个目录 |
| `OPTIONS` | 协议探测 | 返回支持的 WebDAV 方法和功能 |
| `MKCOL` | 创建目录 | 创建新的文件夹(自动支持) |
## 4.3 部署配置
### 4.3.1 环境变量设置
需要在 Cloudflare Worker 中设置以下环境变量:
```bash
# WebDAV 认证凭据
AUTH_USER=your_username # WebDAV 登录用户名
AUTH_PASS=your_password # WebDAV 登录密码
# 上游 API 配置
UPSTREAM_HOST=your-imgbed.domain.com # 您的图床域名
API_TOKEN=your_api_token # API 访问令牌
```
### 4.3.2 自定义域名绑定(推荐)
为了获得更好的使用体验,强烈建议为 WebDAV Worker 绑定自定义域名:
1. **准备域名**:确保您有一个可用的域名,并且该域名已托管在 Cloudflare
2. **添加自定义路由**
- 进入 Cloudflare Workers 控制台
- 选择您的 WebDAV Worker
- 点击 `触发器` (Triggers) 标签
- 点击 `添加自定义域名`
- 输入您的子域名,如:`webdav.yourdomain.com`
- 点击 `添加域名`
3. **SSL 证书**Cloudflare 会自动为您的自定义域名提供免费 SSL 证书
**使用自定义域名的优势**
- 🌟 **更好的兼容性**:避免某些 WebDAV 客户端对 `.workers.dev` 域名的限制
- 🔒 **更高的安全性**:自定义域名通常更受客户端信任
- 📱 **移动端友好**iOS/Android 设备对自定义域名支持更好
- 🎯 **品牌一致性**:与您的图床服务使用统一的域名体系
## 4.4 使用方式
### 浏览器访问
直接在浏览器中访问 Worker 地址,输入认证信息后可以浏览文件目录:
```
# 使用自定义域名(推荐)
https://webdav.yourdomain.com/
# 或使用默认 Worker 域名
https://your-webdav-worker.your-subdomain.workers.dev/
```
### WebDAV 客户端
可以使用任何支持 WebDAV 的客户端连接:
**Windows 资源管理器**
1. 打开"此电脑"
2. 右键选择"添加网络位置"
3. 输入 WebDAV Worker 地址
4. 输入用户名和密码
**macOS Finder**
1. 在 Finder 中按 `Cmd+K`
2. 输入 WebDAV 地址(推荐使用自定义域名):
- `https://webdav.yourdomain.com`
- `https://your-webdav-worker.your-subdomain.workers.dev`
3. 输入认证信息
**第三方客户端**
- Cyberduck、WinSCP、FileZilla Pro 等文件管理器
- Mobile 端FE File Explorer、Documents by Readdle 等
## 4.5 特色功能
- **智能路径处理**:自动处理文件路径,支持中文和特殊字符
- **分页加载**:大目录自动分页加载,提升性能
- **错误处理**:完善的错误处理和用户友好的错误信息
- **缓存优化**:合理利用浏览器缓存,提升访问速度
- **安全可靠**:基于 Cloudflare Worker 的边缘计算,全球加速
通过 WebDAV Bridge您可以像使用本地文件夹一样管理托管的文件实现了真正的"云端硬盘"体验!
# 5. Tips
- **前端开源**:参见[MarSeventh/Sanyue-ImgHub](https://github.com/MarSeventh/Sanyue-ImgHub)项目。

View File

@@ -102,7 +102,110 @@ Provides detailed deployment documentation, feature docs, development plans, upd
</details>
# 4. Tips
# 4. WebDAV Bridge Service
This project provides a powerful **WebDAV Bridge Cloudflare Worker** that allows you to access and manage hosted files through the standard WebDAV protocol.
## 4.1 Features
- 🔒 **Authentication**: Supports Basic Auth authentication with username/password
- 📁 **Directory Browsing**: Complete directory structure display, supports both HTML pages and WebDAV clients
- 📤 **File Upload**: Upload files to specified directories using PUT method
- 🗑️ **File Deletion**: Support for deleting individual files or entire folders
- 📥 **File Download**: Direct file downloads with automatic proxy to upstream storage
- 🌐 **CORS Support**: Built-in CORS support ensuring proper web client access
## 4.2 Supported WebDAV Methods
| Method | Function | Description |
|--------|----------|-------------|
| `PROPFIND` | List directory contents | Get file and folder lists, supports WebDAV clients |
| `GET` | Download file/browse directory | File downloads or HTML directory browsing pages |
| `PUT` | Upload file | Upload files to specified paths and folders |
| `DELETE` | Delete file/folder | Support for deleting individual files or entire directories |
| `OPTIONS` | Protocol detection | Returns supported WebDAV methods and features |
| `MKCOL` | Create directory | Create new folders (automatically supported) |
## 4.3 Deployment Configuration
### 4.3.1 Environment Variables
Set the following environment variables in your Cloudflare Worker:
```bash
# WebDAV authentication credentials
AUTH_USER=your_username # WebDAV login username
AUTH_PASS=your_password # WebDAV login password
# Upstream API configuration
UPSTREAM_HOST=your-imgbed.domain.com # Your image bed domain
API_TOKEN=your_api_token # API access token
```
### 4.3.2 Custom Domain Binding (Recommended)
For a better user experience, it's highly recommended to bind a custom domain to your WebDAV Worker:
1. **Prepare Domain**: Ensure you have an available domain that is hosted on Cloudflare
2. **Add Custom Route**:
- Go to Cloudflare Workers console
- Select your WebDAV Worker
- Click the `Triggers` tab
- Click `Add Custom Domain`
- Enter your subdomain, e.g.: `webdav.yourdomain.com`
- Click `Add Domain`
3. **SSL Certificate**: Cloudflare will automatically provide a free SSL certificate for your custom domain
**Advantages of Using Custom Domain**:
- 🌟 **Better Compatibility**: Avoid limitations some WebDAV clients have with `.workers.dev` domains
- 🔒 **Enhanced Security**: Custom domains are generally more trusted by clients
- 📱 **Mobile Friendly**: iOS/Android devices have better support for custom domains
- 🎯 **Brand Consistency**: Use a unified domain system with your image hosting service
## 4.4 Usage
### Browser Access
Access the Worker address directly in your browser and enter authentication credentials to browse file directories:
```
# Using custom domain (recommended)
https://webdav.yourdomain.com/
# Or using default Worker domain
https://your-webdav-worker.your-subdomain.workers.dev/
```
### WebDAV Clients
You can use any WebDAV-compatible client to connect:
**Windows File Explorer**:
1. Open "This PC"
2. Right-click and select "Add a network location"
3. Enter the WebDAV Worker address
4. Enter username and password
**macOS Finder**:
1. Press `Cmd+K` in Finder
2. Enter WebDAV address (custom domain recommended):
- `https://webdav.yourdomain.com` or
- `https://your-webdav-worker.your-subdomain.workers.dev`
3. Enter authentication credentials
**Third-party Clients**:
- File managers like Cyberduck, WinSCP, FileZilla Pro
- Mobile: FE File Explorer, Documents by Readdle, etc.
## 4.5 Key Features
- **Smart Path Handling**: Automatic path processing with support for Chinese characters and special characters
- **Paginated Loading**: Large directories are automatically paginated for improved performance
- **Error Handling**: Comprehensive error handling with user-friendly error messages
- **Cache Optimization**: Proper browser caching utilization for improved access speed
- **Secure & Reliable**: Based on Cloudflare Worker edge computing with global acceleration
Through WebDAV Bridge, you can manage hosted files just like using a local folder, achieving a true "cloud drive" experience!
# 5. Tips
- Frontend is open source, see [MarSeventh/Sanyue-ImgHub](https://github.com/MarSeventh/Sanyue-ImgHub).

View File

@@ -0,0 +1,262 @@
// Cloudflare Worker: WebDAV Bridge (v8 - Final version with /file/ prefix for downloads)
export default {
async fetch(request, env) {
const authResponse = checkAuth(request, env);
if (authResponse) return authResponse;
switch (request.method) {
case 'OPTIONS': return handleOptions(request);
case 'PROPFIND': return handlePropfind(request, env);
case 'PUT': return handlePut(request, env);
case 'DELETE': return handleDelete(request, env);
case 'GET': return handleGet(request, env);
case 'MKCOL': return new Response(null, { status: 201 });
default: return new Response('Method Not Allowed', { status: 405 });
}
},
};
// --- UTILITY FUNCTIONS ---
function getApiHeaders(env) {
return {
'Authorization': `Bearer ${env.API_TOKEN}`,
'User-Agent': 'Cloudflare-WebDAV-Worker'
};
}
function checkAuth(request, env) {
const authHeader = request.headers.get('Authorization');
if (!authHeader) {
return new Response('Authorization required', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="WebDAV"' },
});
}
const [scheme, encoded] = authHeader.split(' ');
if (scheme !== 'Basic' || !encoded) {
return new Response('Malformed Authorization header', { status: 400 });
}
const [user, pass] = atob(encoded).split(':');
if (user !== env.AUTH_USER || pass !== env.AUTH_PASS) {
return new Response('Invalid credentials', { status: 403 });
}
return null;
}
// --- WEBDAV METHOD HANDLERS ---
function handleOptions(request) {
return new Response(null, {
status: 204,
headers: {
'Allow': 'OPTIONS, GET, PUT, DELETE, PROPFIND, MKCOL',
'DAV': '1, 2',
'MS-Author-Via': 'DAV',
},
});
}
async function handleGet(request, env) {
const path = decodeURIComponent(new URL(request.url).pathname);
if (path.endsWith('/')) { // Directory listing
try {
const dir = path === '/' ? '' : path.substring(1, path.length - 1);
const contents = await fetchDirectoryContents(dir, env);
const html = generateDirectoryListingHtml(path, contents);
return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
} catch (error) {
console.error('GET (directory) failed:', error.stack);
return new Response(`Error listing directory: ${error.message}`, { status: 500 });
}
} else { // File download
try {
// **FINAL FIX:** Added the mandatory /file/ prefix for the upstream URL.
// The `path` variable already includes the leading slash (e.g., /folder/image.jpg).
const fileUrl = `https://${env.UPSTREAM_HOST}/file${path}`;
const fileResponse = await fetch(fileUrl);
if (!fileResponse.ok) {
return new Response('File not found', { status: fileResponse.status, statusText: fileResponse.statusText });
}
const response = new Response(fileResponse.body, fileResponse);
response.headers.set('Access-Control-Allow-Origin', '*');
return response;
} catch (error) {
console.error('GET (file) failed:', error.stack);
return new Response(`Error getting file: ${error.message}`, { status: 500 });
}
}
}
async function handlePut(request, env) {
const fullPath = decodeURIComponent(new URL(request.url).pathname.substring(1));
if (!fullPath || fullPath.endsWith('/')) {
return new Response('Invalid file name', { status: 400 });
}
const lastSlashIndex = fullPath.lastIndexOf('/');
const uploadFolder = lastSlashIndex > -1 ? fullPath.substring(0, lastSlashIndex) : '';
const fileName = lastSlashIndex > -1 ? fullPath.substring(lastSlashIndex + 1) : fullPath;
const fileContent = await request.blob();
const formData = new FormData();
formData.append('file', fileContent, fileName);
const uploadUrl = new URL(`https://${env.UPSTREAM_HOST}/upload`);
if (uploadFolder) {
uploadUrl.searchParams.set('uploadFolder', uploadFolder);
}
try {
const response = await fetch(uploadUrl.toString(), {
method: 'POST',
body: formData,
headers: getApiHeaders(env)
});
const result = await response.json();
if (response.ok && Array.isArray(result) && result.length > 0 && result[0].src) {
return new Response(null, { status: 201 }); // Created
} else {
const errorMsg = result.error || JSON.stringify(result);
console.error('Upload API error:', errorMsg);
return new Response(`Upload failed: ${errorMsg}`, { status: 500 });
}
} catch (error) {
console.error('Fetch to upload API failed:', error.stack);
return new Response('Failed to contact upload service', { status: 502 });
}
}
async function handleDelete(request, env) {
const path = decodeURIComponent(new URL(request.url).pathname.substring(1));
if (!path) return new Response('Invalid path for DELETE', { status: 400 });
const isFolder = path.endsWith('/');
const cleanPath = isFolder ? path.slice(0, -1) : path;
const deleteUrl = new URL(`https://${env.UPSTREAM_HOST}/api/manage/delete/${cleanPath}`);
if (isFolder) deleteUrl.searchParams.set('folder', 'true');
try {
const response = await fetch(deleteUrl.toString(), {
method: 'DELETE',
headers: getApiHeaders(env)
});
const result = await response.json();
if (result.success) {
return new Response(null, { status: 204 }); // No Content
} else {
console.error('Delete API error:', JSON.stringify(result));
return new Response(`Deletion failed: ${result.error || 'API error'}`, { status: 500 });
}
} catch (error) {
console.error('Delete operation failed:', error.stack);
return new Response(`Internal server error: ${error.message}`, { status: 500 });
}
}
async function handlePropfind(request, env) {
const path = decodeURIComponent(new URL(request.url).pathname);
try {
const dir = path === '/' ? '' : path.substring(1, path.endsWith('/') ? path.length - 1 : path.length);
const contents = await fetchDirectoryContents(dir, env);
const xml = generateWebDAVXml(path, contents);
return new Response(xml, { status: 207, headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
} catch (error) {
console.error('Propfind failed:', error.stack);
return new Response(`Failed to list files: ${error.message}`, { status: 500 });
}
}
// --- API DATA FETCHING ---
async function fetchDirectoryContents(dir, env) {
let allFiles = [];
let allDirectories = [];
let start = 0;
const count = 100;
while (true) {
const listUrl = new URL(`https://${env.UPSTREAM_HOST}/api/manage/list`);
listUrl.searchParams.set('dir', dir);
listUrl.searchParams.set('start', start);
listUrl.searchParams.set('count', count);
const response = await fetch(listUrl.toString(), { headers: getApiHeaders(env) });
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API fetch error: Status ${response.status} - ${errorText}`);
}
const result = await response.json();
if (result.error) {
throw new Error(`API error: ${result.error} - ${result.message}`);
}
if (result.files && result.files.length > 0) allFiles = allFiles.concat(result.files);
if (result.directories && result.directories.length > 0) allDirectories = allDirectories.concat(result.directories);
if (!result.files || result.files.length < count) break;
start += count;
}
return { files: allFiles, directories: [...new Set(allDirectories)] };
}
// --- HTML and XML GENERATION ---
function generateDirectoryListingHtml(basePath, contents) {
let fileLinks = '';
let dirLinks = '';
for (const dir of contents.directories) {
const fullDirPath = `/${dir}/`;
const dirName = dir.split('/').pop();
dirLinks += `<li><a href="${fullDirPath}"><strong>${dirName}/</strong></a></li>`;
}
for (const file of contents.files) {
const fullFilePath = `/${file.name}`;
const fileName = file.name.split('/').pop();
const fileSize = file.metadata && file.metadata['File-Size']
? `${Math.round(parseInt(file.metadata['File-Size']) / 1024)} KB`
: 'N/A';
fileLinks += `<li><a href="${fullFilePath}">${fileName}</a> - ${fileSize}</li>`;
}
let parentDirLink = '';
if (basePath !== '/') {
const parentPath = new URL('..', `http://dummy.com${basePath}`).pathname;
parentDirLink = `<li><a href="${parentPath}"><strong>../ (Parent Directory)</strong></a></li>`;
}
return `<!DOCTYPE html><html><head><title>Index of ${basePath}</title><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:sans-serif;padding:20px}li{margin:5px 0}</style></head><body><h1>Index of ${basePath}</h1><ul>${parentDirLink}${dirLinks}${fileLinks}</ul></body></html>`;
}
function generateWebDAVXml(basePath, contents) {
let responses = '';
const currentPath = basePath.endsWith('/') ? basePath : `${basePath}/`;
responses += createCollectionXml(currentPath);
for (const dir of contents.directories) {
responses += createCollectionXml(`/${dir}/`);
}
for (const file of contents.files) {
responses += createFileXml(file);
}
return `<?xml version="1.0" encoding="utf-8"?><D:multistatus xmlns:D="DAV:">${responses}</D:multistatus>`;
}
function createCollectionXml(path) {
const now = new Date().toUTCString();
const cleanPath = path.endsWith('/') ? path.slice(0, -1) : path;
const name = cleanPath.split('/').pop() || '';
return `<D:response><D:href>${encodeURI(path)}</D:href><D:propstat><D:prop><D:displayname>${name}</D:displayname><D:resourcetype><D:collection/></D:resourcetype><D:creationdate>${now}</D:creationdate><D:getlastmodified>${now}</D:getlastmodified></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`;
}
function createFileXml(file) {
const now = new Date().toUTCString();
const fileSize = file.metadata && file.metadata['File-Size'] ? file.metadata['File-Size'] : "0";
return `<D:response><D:href>${encodeURI(`/${file.name}`)}</D:href><D:propstat><D:prop><D:displayname>${file.name.split('/').pop()}</D:displayname><D:resourcetype/><D:creationdate>${now}</D:creationdate><D:getlastmodified>${now}</D:getlastmodified><D:getcontentlength>${fileSize}</D:getcontentlength></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`;
}