using System.Net; using System.Net.Http.Headers; using System.Text; using System.Xml.Linq; using dy.net.model.dto; using dy.net.model.entity; using dy.net.service; namespace dy.net.storage { public class WebDavMediaStorage : IMediaStorage { private static readonly TimeSpan[] DefaultUploadVisibilityRetryDelays = { TimeSpan.Zero, TimeSpan.FromMilliseconds(200), TimeSpan.FromMilliseconds(400), TimeSpan.FromMilliseconds(800), TimeSpan.FromMilliseconds(1600), TimeSpan.FromMilliseconds(3200) }; private static readonly TimeSpan[] DefaultFinalVisibilityRetryDelays = { TimeSpan.Zero, TimeSpan.FromMilliseconds(200), TimeSpan.FromMilliseconds(400), TimeSpan.FromMilliseconds(800), TimeSpan.FromMilliseconds(1600), TimeSpan.FromMilliseconds(3200), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(12), TimeSpan.FromSeconds(15) }; private static readonly TimeSpan[] DirectoryConflictRetryDelays = { TimeSpan.Zero, TimeSpan.FromMilliseconds(150), TimeSpan.FromMilliseconds(400), TimeSpan.FromMilliseconds(900), TimeSpan.FromMilliseconds(2000), TimeSpan.FromMilliseconds(4500), TimeSpan.FromMilliseconds(8000) }; private readonly IHttpClientFactory _clientFactory; private readonly WebDavSettingsService _settingsService; private readonly IReadOnlyList _uploadVisibilityRetryDelays; private readonly IReadOnlyList _finalVisibilityRetryDelays; public WebDavMediaStorage(IHttpClientFactory clientFactory, WebDavSettingsService settingsService) : this(clientFactory, settingsService, DefaultUploadVisibilityRetryDelays, DefaultFinalVisibilityRetryDelays) { } internal WebDavMediaStorage( IHttpClientFactory clientFactory, WebDavSettingsService settingsService, IReadOnlyList uploadVisibilityRetryDelays, IReadOnlyList finalVisibilityRetryDelays) { _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); _uploadVisibilityRetryDelays = uploadVisibilityRetryDelays?.Count > 0 ? uploadVisibilityRetryDelays : throw new ArgumentException("上传校验重试间隔不能为空", nameof(uploadVisibilityRetryDelays)); _finalVisibilityRetryDelays = finalVisibilityRetryDelays?.Count > 0 ? finalVisibilityRetryDelays : throw new ArgumentException("MOVE 校验重试间隔不能为空", nameof(finalVisibilityRetryDelays)); } public StorageType StorageType => StorageType.WebDav; public async Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default) => await EnsureDirectoryAsync(path, await _settingsService.GetAsync(), cancellationToken); public async Task ExistsAsync(string path, CancellationToken cancellationToken = default) => !string.IsNullOrWhiteSpace(path) && await ExistsAsync(path, await _settingsService.GetAsync(), cancellationToken); public async Task GetLengthAsync(string path, CancellationToken cancellationToken = default) => string.IsNullOrWhiteSpace(path) ? null : await GetLengthAsync(path, await _settingsService.GetAsync(), cancellationToken); public async Task WriteAsync(string path, Stream source, long? contentLength = null, string contentType = null, CancellationToken cancellationToken = default) => await WriteAsync(path, source, contentLength, contentType, await _settingsService.GetAsync(), cancellationToken); public async Task OpenReadAsync(string path, long? from = null, long? to = null, CancellationToken cancellationToken = default) => await OpenReadAsync(path, from, to, await _settingsService.GetAsync(), cancellationToken); public async Task DeleteAsync(string path, CancellationToken cancellationToken = default) { if (!string.IsNullOrWhiteSpace(path)) await DeleteAsync(path, await _settingsService.GetAsync(), cancellationToken); } public IMediaStorage Bind(WebDavSettings settings) { Validate(settings); return new BoundWebDavMediaStorage(this, settings); } public async Task<(bool Success, string Message)> ProbeAsync(WebDavSettings settings, CancellationToken cancellationToken = default) { Validate(settings); var probeDirectory = $"/.dysync-probe-{Guid.NewGuid():N}"; var sourcePath = StoragePath.CombineRemote(probeDirectory, "source.txt"); var movedPath = StoragePath.CombineRemote(probeDirectory, "moved.txt"); var bytes = Encoding.UTF8.GetBytes("dysync-webdav-probe"); try { await EnsureDirectoryAsync(probeDirectory, settings, cancellationToken); await using (var source = new MemoryStream(bytes, false)) await WriteAsync(sourcePath, source, bytes.Length, "text/plain", settings, cancellationToken); var length = await GetLengthAsync(sourcePath, settings, cancellationToken); if (length != bytes.Length) return (false, $"上传长度校验失败,期望 {bytes.Length},实际 {length?.ToString() ?? "未知"}"); await MoveAsync(sourcePath, movedPath, settings, cancellationToken); await using (var read = await OpenReadAsync(movedPath, 0, 0, settings, cancellationToken)) { if (read.StatusCode != (int)HttpStatusCode.PartialContent || !ContentRangeHeaderValue.TryParse(read.ContentRange, out var contentRange) || !contentRange.HasRange || contentRange.Unit != "bytes" || contentRange.From != 0 || contentRange.To != 0 || !contentRange.HasLength || contentRange.Length < 1) return (false, "服务不支持媒体播放所需的标准 Range 读取(需要 206 和有效 Content-Range)"); } await DeleteAsync(movedPath, settings, cancellationToken); if (await ExistsAsync(movedPath, settings, cancellationToken)) return (false, "服务未能删除能力探测文件"); return (true, "AList/OpenList WebDAV 读写、移动、Range 与删除能力正常"); } catch (Exception ex) { Serilog.Log.Warning(ex, "WebDAV 能力探测失败"); return (false, ex.Message); } finally { await TryDeleteAsync(sourcePath, settings, CancellationToken.None); await TryDeleteAsync(movedPath, settings, CancellationToken.None); await TryDeleteAsync(probeDirectory, settings, CancellationToken.None); } } private async Task EnsureDirectoryAsync(string path, WebDavSettings settings, CancellationToken cancellationToken) { Validate(settings); var combined = CombinedPath(settings, path); var segments = combined.Split('/', StringSplitOptions.RemoveEmptyEntries); var current = string.Empty; foreach (var segment in segments) { current = StoragePath.CombineRemote(current, segment); await EnsureDirectorySegmentAsync(current, settings, cancellationToken); } } private async Task EnsureDirectorySegmentAsync( string path, WebDavSettings settings, CancellationToken cancellationToken) { HttpStatusCode lastStatus = 0; foreach (var delay in DirectoryConflictRetryDelays) { if (delay > TimeSpan.Zero) await Task.Delay(delay, cancellationToken); using var request = await CreateRequestAsync(new HttpMethod("MKCOL"), path, settings); using var response = await SendAsync( request, settings, HttpCompletionOption.ResponseContentRead, cancellationToken); lastStatus = response.StatusCode; if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.MethodNotAllowed) return; if (response.StatusCode != HttpStatusCode.Conflict) break; // AList/OpenList and some mounted storage drivers may return 409 when // another request has just created the same collection, or while a // newly-created parent is becoming visible. Only reuse the path after // PROPFIND confirms that it is a collection; otherwise retry briefly. if (await DirectoryExistsAsync(path, settings, cancellationToken)) return; } throw new HttpRequestException($"创建 WebDAV 目录失败:{path} ({(int)lastStatus})"); } private async Task DirectoryExistsAsync( string combinedPath, WebDavSettings settings, CancellationToken cancellationToken) { using var request = await CreateRequestAsync(new HttpMethod("PROPFIND"), combinedPath, settings); request.Headers.TryAddWithoutValidation("Depth", "0"); request.Content = new StringContent( "", Encoding.UTF8, "application/xml"); using var response = await SendAsync( request, settings, HttpCompletionOption.ResponseContentRead, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) return false; if (!response.IsSuccessStatusCode && (int)response.StatusCode != 207) return false; try { var xml = XDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); return xml.Descendants().Any(x => x.Name.LocalName == "collection"); } catch { return false; } } private async Task ExistsAsync(string path, WebDavSettings settings, CancellationToken cancellationToken) { Validate(settings); using var request = await CreateRequestAsync(new HttpMethod("PROPFIND"), CombinedPath(settings, path), settings); request.Headers.TryAddWithoutValidation("Depth", "0"); using var response = await SendAsync(request, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) return false; return response.IsSuccessStatusCode || (int)response.StatusCode == 207; } private async Task GetLengthAsync(string path, WebDavSettings settings, CancellationToken cancellationToken) { Validate(settings); using (var head = await CreateRequestAsync(HttpMethod.Head, CombinedPath(settings, path), settings)) { AddNoCacheHeaders(head); using var response = await SendAsync(head, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) return null; if (response.IsSuccessStatusCode && response.Content.Headers.ContentLength.HasValue) return response.Content.Headers.ContentLength.Value; } using var request = await CreateRequestAsync(new HttpMethod("PROPFIND"), CombinedPath(settings, path), settings); AddNoCacheHeaders(request); request.Headers.TryAddWithoutValidation("Depth", "0"); request.Content = new StringContent("", Encoding.UTF8, "application/xml"); using var propResponse = await SendAsync(request, settings, HttpCompletionOption.ResponseContentRead, cancellationToken); if (propResponse.StatusCode == HttpStatusCode.NotFound) return null; propResponse.EnsureSuccessStatusCode(); var xml = XDocument.Parse(await propResponse.Content.ReadAsStringAsync(cancellationToken)); var value = xml.Descendants().FirstOrDefault(x => x.Name.LocalName == "getcontentlength")?.Value; return long.TryParse(value, out var length) ? length : null; } private async Task WriteAsync(string path, Stream source, long? contentLength, string contentType, WebDavSettings settings, CancellationToken cancellationToken) { Validate(settings); path = StoragePath.NormalizeRemote(path); if (path == "/") throw new ArgumentException("WebDAV 文件路径不能为空", nameof(path)); await EnsureDirectoryAsync(StoragePath.DirectoryName(path), settings, cancellationToken); var temporaryPath = path + ".part-" + Guid.NewGuid().ToString("N"); try { using var put = await CreateRequestAsync(HttpMethod.Put, CombinedPath(settings, temporaryPath), settings); put.Content = new StreamContent(source, 81920); if (contentLength.HasValue) put.Content.Headers.ContentLength = contentLength; if (!string.IsNullOrWhiteSpace(contentType)) put.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); using var response = await SendAsync(put, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); var actualLength = await WaitForReadableLengthAsync( temporaryPath, contentLength, settings, _uploadVisibilityRetryDelays, cancellationToken); if (contentLength.HasValue && actualLength != contentLength) throw new IOException($"WebDAV 上传长度不一致:期望 {contentLength},实际 {actualLength?.ToString() ?? "未知"}"); if (!actualLength.HasValue || actualLength <= 0) throw new IOException("WebDAV 上传后的临时文件不存在或为空"); await MoveAsync(temporaryPath, path, settings, cancellationToken); var expectedFinalLength = contentLength ?? actualLength; var finalLength = await WaitForReadableLengthAsync( path, expectedFinalLength, settings, _finalVisibilityRetryDelays, cancellationToken); if (!finalLength.HasValue || finalLength <= 0) throw new IOException($"WebDAV MOVE 后等待约 {TotalDelaySeconds(_finalVisibilityRetryDelays):0.#} 秒,最终文件仍不存在或为空"); if (expectedFinalLength.HasValue && expectedFinalLength > 0 && finalLength != expectedFinalLength) throw new IOException($"WebDAV MOVE 后等待约 {TotalDelaySeconds(_finalVisibilityRetryDelays):0.#} 秒,最终文件仍返回旧长度或长度不一致:期望 {expectedFinalLength},实际 {finalLength}"); } finally { await TryDeleteAsync(temporaryPath, settings, CancellationToken.None); } } private async Task MoveAsync(string sourcePath, string destinationPath, WebDavSettings settings, CancellationToken cancellationToken) { using var request = await CreateRequestAsync(new HttpMethod("MOVE"), CombinedPath(settings, sourcePath), settings); request.Headers.TryAddWithoutValidation("Destination", BuildUri(settings, CombinedPath(settings, destinationPath)).AbsoluteUri); request.Headers.TryAddWithoutValidation("Overwrite", "T"); using var response = await SendAsync(request, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); } private async Task WaitForReadableLengthAsync( string path, long? expectedLength, WebDavSettings settings, IReadOnlyList retryDelays, CancellationToken cancellationToken) { long? lastLength = null; for (var attempt = 0; attempt < retryDelays.Count; attempt++) { var delay = retryDelays[attempt]; if (delay > TimeSpan.Zero) await Task.Delay(delay, cancellationToken); lastLength = await GetLengthAsync(path, settings, cancellationToken); if (IsUsableLength(lastLength, expectedLength)) return lastLength; // Some AList/OpenList storage drivers acknowledge MOVE before their metadata // cache can answer HEAD/PROPFIND. A one-byte ranged read checks the actual object // without downloading the whole media and also yields its total length. var readableLength = await TryGetLengthFromRangeAsync(path, settings, cancellationToken); if (IsUsableLength(readableLength, expectedLength)) { if (attempt > 0 || !lastLength.HasValue) Serilog.Log.Debug("WebDAV 元数据延迟,已通过 Range 读取确认文件:{Path}", path); return readableLength; } if (readableLength.HasValue) lastLength = readableLength; } return lastLength; } private async Task TryGetLengthFromRangeAsync( string path, WebDavSettings settings, CancellationToken cancellationToken) { using var request = await CreateRequestAsync(HttpMethod.Get, CombinedPath(settings, path), settings); AddNoCacheHeaders(request); request.Headers.Range = new RangeHeaderValue(0, 0); using var response = await SendAsync(request, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) return null; if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable) return response.Content.Headers.ContentRange?.Length ?? 0; response.EnsureSuccessStatusCode(); if (response.Content.Headers.ContentRange?.Length is long totalLength) return totalLength; return response.StatusCode == HttpStatusCode.OK ? response.Content.Headers.ContentLength : null; } private static bool IsUsableLength(long? actualLength, long? expectedLength) => actualLength > 0 && (!expectedLength.HasValue || expectedLength.Value <= 0 || actualLength == expectedLength); private static void AddNoCacheHeaders(HttpRequestMessage request) { request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true, NoStore = true, MaxAge = TimeSpan.Zero }; request.Headers.Pragma.ParseAdd("no-cache"); } private static double TotalDelaySeconds(IEnumerable delays) => delays.Sum(delay => delay.TotalSeconds); private async Task OpenReadAsync(string path, long? from, long? to, WebDavSettings settings, CancellationToken cancellationToken) { Validate(settings); var request = await CreateRequestAsync(HttpMethod.Get, CombinedPath(settings, path), settings); if (from.HasValue) request.Headers.Range = new RangeHeaderValue(from, to); var response = await SendAsync(request, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken); request.Dispose(); if (response.StatusCode == HttpStatusCode.NotFound) { response.Dispose(); throw new FileNotFoundException("WebDAV 媒体文件不存在", path); } response.EnsureSuccessStatusCode(); return new StorageReadResult { Stream = await response.Content.ReadAsStreamAsync(cancellationToken), ContentLength = response.Content.Headers.ContentLength, ContentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream", ContentRange = response.Content.Headers.ContentRange?.ToString(), StatusCode = (int)response.StatusCode, Owner = new HttpResponseOwner(response) }; } private async Task DeleteAsync(string path, WebDavSettings settings, CancellationToken cancellationToken) { Validate(settings); using var request = await CreateRequestAsync(HttpMethod.Delete, CombinedPath(settings, path), settings); using var response = await SendAsync(request, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (response.StatusCode != HttpStatusCode.NotFound) response.EnsureSuccessStatusCode(); } private async Task TryDeleteAsync(string path, WebDavSettings settings, CancellationToken cancellationToken) { try { await DeleteAsync(path, settings, cancellationToken); } catch (Exception ex) { Serilog.Log.Debug(ex, "清理 WebDAV 临时路径失败:{Path}", path); } } private async Task CreateRequestAsync(HttpMethod method, string path, WebDavSettings settings) { var request = new HttpRequestMessage(method, BuildUri(settings, path)); var password = await _settingsService.GetPasswordAsync(settings); var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{settings.UserName}:{password}")); request.Headers.Authorization = new AuthenticationHeaderValue("Basic", token); return request; } private async Task SendAsync(HttpRequestMessage request, WebDavSettings settings, HttpCompletionOption option, CancellationToken cancellationToken) { var client = _clientFactory.CreateClient(settings.AllowInvalidCertificate ? "webdav-insecure" : "webdav"); return await client.SendAsync(request, option, cancellationToken); } private static string CombinedPath(WebDavSettings settings, string path) => StoragePath.CombineRemote(settings.BasePath, path); private static Uri BuildUri(WebDavSettings settings, string combinedPath) { var endpoint = settings.Endpoint.TrimEnd('/'); return new Uri(endpoint + StoragePath.Encode(combinedPath), UriKind.Absolute); } private static void Validate(WebDavSettings settings) { if (settings == null) throw new InvalidOperationException("尚未配置 WebDAV"); if (!Uri.TryCreate(settings.Endpoint, UriKind.Absolute, out var endpoint) || (endpoint.Scheme != "http" && endpoint.Scheme != "https")) throw new InvalidOperationException("WebDAV 地址必须是有效的 HTTP/HTTPS 地址,例如 http://host:5244/dav"); if (string.IsNullOrWhiteSpace(settings.UserName)) throw new InvalidOperationException("WebDAV 用户名不能为空"); StoragePath.NormalizeRemote(settings.BasePath); } private sealed class BoundWebDavMediaStorage : IMediaStorage { private readonly WebDavMediaStorage _owner; private readonly WebDavSettings _settings; public BoundWebDavMediaStorage(WebDavMediaStorage owner, WebDavSettings settings) { _owner = owner; _settings = settings; } public StorageType StorageType => StorageType.WebDav; public Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default) => _owner.EnsureDirectoryAsync(path, _settings, cancellationToken); public Task ExistsAsync(string path, CancellationToken cancellationToken = default) => string.IsNullOrWhiteSpace(path) ? Task.FromResult(false) : _owner.ExistsAsync(path, _settings, cancellationToken); public Task GetLengthAsync(string path, CancellationToken cancellationToken = default) => string.IsNullOrWhiteSpace(path) ? Task.FromResult(null) : _owner.GetLengthAsync(path, _settings, cancellationToken); public Task WriteAsync(string path, Stream source, long? contentLength = null, string contentType = null, CancellationToken cancellationToken = default) => _owner.WriteAsync(path, source, contentLength, contentType, _settings, cancellationToken); public Task OpenReadAsync(string path, long? from = null, long? to = null, CancellationToken cancellationToken = default) => _owner.OpenReadAsync(path, from, to, _settings, cancellationToken); public Task DeleteAsync(string path, CancellationToken cancellationToken = default) => string.IsNullOrWhiteSpace(path) ? Task.CompletedTask : _owner.DeleteAsync(path, _settings, cancellationToken); } } }