feat(storage): add WebDAV storage support #4

This commit is contained in:
ShiYu
2025-05-22 21:48:07 +08:00
parent fba716ba28
commit c00812e653
5 changed files with 287 additions and 2 deletions

View File

@@ -8,12 +8,15 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Foxel.Services.Configuration;
using Foxel.Services.Media;
using Foxel.Services.Storage;
using System.IO;
using Foxel.Services.Attributes;
namespace Foxel.Controllers;
[Authorize]
[Route("api/picture")]
public class PictureController(IPictureService pictureService,IConfigService configService) : BaseApiController
public class PictureController(IPictureService pictureService, IConfigService configService, IStorageService storageService) : BaseApiController
{
[HttpGet("get_pictures")]
public async Task<ActionResult<PaginatedResult<PictureResponse>>> GetPictures(
@@ -321,4 +324,52 @@ public class PictureController(IPictureService pictureService,IConfigService con
[JsonPropertyName("file_path")]
public string? FilePath { get; set; }
}
[HttpGet("proxy")]
[AllowAnonymous]
public async Task<IActionResult> GetWebDavFile([FromQuery] string path)
{
try
{
if (string.IsNullOrEmpty(path))
{
return BadRequest("文件路径不能为空");
}
// 下载文件到临时位置
string filePath = await storageService.DownloadFileAsync(StorageType.WebDAV,path);
// 确定内容类型
string contentType = GetContentTypeFromPath(path);
// 返回文件内容
return PhysicalFile(filePath, contentType, Path.GetFileName(path));
}
catch (Exception ex)
{
return StatusCode(500, $"代理获取WebDAV文件失败: {ex.Message}");
}
}
private string GetContentTypeFromPath(string path)
{
string extension = Path.GetExtension(path).ToLowerInvariant();
return extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".bmp" => "image/bmp",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".mp4" => "video/mp4",
".avi" => "video/x-msvideo",
".mov" => "video/quicktime",
".pdf" => "application/pdf",
".doc" or ".docx" => "application/msword",
".xls" or ".xlsx" => "application/vnd.ms-excel",
_ => "application/octet-stream"
};
}
}

View File

@@ -33,6 +33,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<TelegramStorageProvider>();
services.AddSingleton<S3StorageProvider>();
services.AddSingleton<CosStorageProvider>();
services.AddSingleton<WebDavStorageProvider>();
services.AddSingleton<IStorageService, StorageService>();
services.AddSingleton<IDatabaseInitializer, DatabaseInitializer>();
}

View File

@@ -0,0 +1,209 @@
using System.Net.Http.Headers;
using System.Text;
using Foxel.Services.Attributes;
using Foxel.Services.Configuration;
namespace Foxel.Services.Storage.Providers;
[StorageProvider(StorageType.WebDAV)]
public class WebDavStorageProvider : IStorageProvider
{
private readonly string _webDavServerUrl;
private readonly string _serverUrl;
private readonly string _basePath;
private readonly string _publicUrl;
private readonly HttpClient _httpClient;
public WebDavStorageProvider(IConfigService configService)
{
_webDavServerUrl = configService["Storage:WebDAVServerUrl"].TrimEnd('/');
var userName = configService["Storage:WebDAVUserName"];
var password = configService["Storage:WebDAVPassword"];
_basePath = configService["Storage:WebDAVBasePath"].Trim('/');
_publicUrl = configService["Storage:WebDAVPublicUrl"].TrimEnd('/');
_serverUrl = configService["AppSettings:ServerUrl"];
_httpClient = new HttpClient();
if (!string.IsNullOrEmpty(userName) && !string.IsNullOrEmpty(password))
{
var authValue = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{userName}:{password}"));
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue);
}
}
public async Task<string> SaveAsync(Stream fileStream, string fileName, string contentType)
{
try
{
// 创建唯一的文件存储路径
string currentDate = DateTime.Now.ToString("yyyy/MM");
string ext = Path.GetExtension(fileName);
string newFileName = $"{Guid.NewGuid()}{ext}";
string relativePath = $"{_basePath}/{currentDate}/{newFileName}";
// 确保目录存在
await EnsureDirectoryExistsAsync($"{_basePath}/{currentDate}");
// 上传文件内容
var requestUri = $"{_webDavServerUrl}/{relativePath}";
using var content = new StreamContent(fileStream);
content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
using var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
request.Content = content;
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return relativePath;
}
catch (Exception ex)
{
Console.WriteLine($"上传文件到WebDAV时出错: {ex.Message}");
throw;
}
}
public async Task DeleteAsync(string storagePath)
{
try
{
if (string.IsNullOrEmpty(storagePath))
return;
var requestUri = $"{_webDavServerUrl}/{storagePath}";
var response = await _httpClient.DeleteAsync(requestUri);
if (response.StatusCode != System.Net.HttpStatusCode.NotFound)
{
response.EnsureSuccessStatusCode();
}
}
catch (Exception ex)
{
Console.WriteLine($"从WebDAV删除文件时出错: {ex.Message}");
}
}
public string GetUrl(string storagePath)
{
try
{
if (string.IsNullOrEmpty(storagePath))
return "/images/unavailable.gif";
if (!string.IsNullOrEmpty(_publicUrl))
{
return $"{_publicUrl}/{storagePath}";
}
return $"{_serverUrl}/api/picture/proxy?path={Uri.EscapeDataString(storagePath)}";
}
catch (Exception ex)
{
Console.WriteLine($"生成WebDAV文件URL时出错: {ex.Message}");
return "/images/unavailable.gif";
}
}
public async Task<string> DownloadFileAsync(string storagePath)
{
try
{
if (string.IsNullOrEmpty(storagePath))
{
throw new ArgumentException("存储路径不能为空");
}
// 创建临时目录
var tempDir = Path.Combine(Path.GetTempPath(), "FoxelWebDAVTemp");
if (!Directory.Exists(tempDir))
{
Directory.CreateDirectory(tempDir);
}
// 创建临时文件名
string fileName = Path.GetFileName(storagePath);
string tempFilePath = Path.Combine(tempDir, fileName);
// 下载文件
var requestUri = $"{_webDavServerUrl}/{storagePath}";
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
await using var fileStream = new FileStream(tempFilePath, FileMode.Create);
await response.Content.CopyToAsync(fileStream);
return tempFilePath;
}
catch (Exception ex)
{
Console.WriteLine($"从WebDAV下载文件时出错: {ex.Message}");
throw;
}
}
/// <summary>
/// 确保WebDAV上的目录存在
/// </summary>
private async Task EnsureDirectoryExistsAsync(string directoryPath)
{
try
{
var requestUri = $"{_webDavServerUrl}/{directoryPath}";
// 检查目录是否存在 - 使用新的请求对象
using var headRequest = new HttpRequestMessage(HttpMethod.Head, requestUri);
var response = await _httpClient.SendAsync(headRequest);
if (response.IsSuccessStatusCode)
return;
// 创建目录 - 使用新的请求对象
using var mkcolRequest = new HttpRequestMessage(new HttpMethod("MKCOL"), requestUri);
response = await _httpClient.SendAsync(mkcolRequest);
// 处理状态码
if (response.StatusCode == System.Net.HttpStatusCode.Conflict ||
response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// 递归创建父目录
var parentPath = Path.GetDirectoryName(directoryPath.TrimEnd('/'))?.Replace('\\', '/');
if (!string.IsNullOrEmpty(parentPath))
{
await EnsureDirectoryExistsAsync(parentPath);
using var retryRequest = new HttpRequestMessage(new HttpMethod("MKCOL"), requestUri);
response = await _httpClient.SendAsync(retryRequest);
if (response.StatusCode == System.Net.HttpStatusCode.MethodNotAllowed)
{
using var putRequest = new HttpRequestMessage(HttpMethod.Put, $"{requestUri}/.dummy");
putRequest.Content = new StringContent(string.Empty);
await _httpClient.SendAsync(putRequest);
}
else
{
response.EnsureSuccessStatusCode();
}
}
}
else if (response.StatusCode == System.Net.HttpStatusCode.MethodNotAllowed)
{
using var putRequest = new HttpRequestMessage(HttpMethod.Put, $"{requestUri}/.dummy");
putRequest.Content = new StringContent(string.Empty);
var putResponse = await _httpClient.SendAsync(putRequest);
putResponse.EnsureSuccessStatusCode();
}
else
{
response.EnsureSuccessStatusCode();
}
}
catch (Exception ex)
{
Console.WriteLine($"确保WebDAV目录存在时出错: {ex.Message}");
throw;
}
}
}

View File

@@ -8,6 +8,7 @@ public enum StorageType
Telegram = 1,
S3 = 2,
Cos = 3,
WebDAV = 4,
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Tabs, Card, message, Spin, Select } from 'antd';
import { CloudOutlined, DatabaseOutlined, CloudServerOutlined } from '@ant-design/icons';
import { CloudOutlined, DatabaseOutlined, CloudServerOutlined, GlobalOutlined } from '@ant-design/icons';
import { getAllConfigs, setConfig } from '../../api';
import ConfigGroup from './ConfigGroup.tsx';
import useIsMobile from '../../hooks/useIsMobile';
@@ -88,6 +88,7 @@ const SystemConfig: React.FC = () => {
{ value: 'Telegram', label: 'Telegram 频道', icon: <CloudOutlined style={{ color: '#0088cc' }} /> },
{ value: 'S3', label: '亚马逊 S3', icon: <CloudServerOutlined style={{ color: '#ff9900' }} /> },
{ value: 'Cos', label: '腾讯云 COS', icon: <CloudServerOutlined style={{ color: '#00a4ff' }} /> },
{ value: 'WebDAV', label: 'WebDAV 存储', icon: <GlobalOutlined style={{ color: '#1890ff' }} /> },
];
useEffect(() => {
@@ -317,6 +318,28 @@ const SystemConfig: React.FC = () => {
isMobile={isMobile}
/>
)}
{storageType === 'WebDAV' && (
<ConfigGroup
groupName="Storage"
configs={{
"WebDAVServerUrl": configs.Storage?.WebDAVServerUrl || '',
"WebDAVUserName": configs.Storage?.WebDAVUserName || '',
"WebDAVPassword": configs.Storage?.WebDAVPassword || '',
"WebDAVBasePath": configs.Storage?.WebDAVBasePath || '',
"WebDAVPublicUrl": configs.Storage?.WebDAVPublicUrl || '',
}}
onSave={handleSaveConfig}
descriptions={{
"WebDAVServerUrl": 'WebDAV 服务器 URL (例如: https://dav.example.com)',
"WebDAVUserName": 'WebDAV 用户名',
"WebDAVPassword": 'WebDAV 密码',
"WebDAVBasePath": 'WebDAV 基础路径 (例如: files/upload)',
"WebDAVPublicUrl": 'WebDAV 公共访问 URL (可选,用于文件访问)',
}}
isMobile={isMobile}
/>
)}
</TabPane>
</Tabs>
)}