using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Xml;
using Sunfish.Shares;
namespace Sunfish.Dav;
/// <summary>Un elemento de una carpeta remota.</summary>
public sealed record DavItem(string Name, string Path, bool IsCollection, long Size, DateTime? Modified);
/// <summary>Error de un servidor WebDAV (401: hace falta usuario y contrasena).</summary>
public sealed class DavException(int status, string message) : Exception(message)
{
public int Status { get; } = status;
}
/// <summary>
/// Cliente WebDAV minimo para explorar otro Sunfish (o cualquier servidor WebDAV) y copiar en los
/// dos sentidos. Las rutas son absolutas y sin codificar ("/fotos/2026"); se codifican aqui.
/// </summary>
public sealed class DavClient : IDisposable
{
private static readonly HttpMethod PropFind = new("PROPFIND");
private static readonly HttpMethod MkCol = new("MKCOL");
private static readonly HttpMethod Move = new("MOVE");
private readonly HttpClient _http;
public DavClient(Uri server, string? user = null, string? password = null)
{
Server = new Uri(server.GetLeftPart(UriPartial.Authority) + "/");
_http = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = true, PooledConnectionLifetime = TimeSpan.FromMinutes(2) })
{
BaseAddress = Server,
Timeout = Timeout.InfiniteTimeSpan,
};
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Sunfish/3.0");
if (!string.IsNullOrEmpty(user))
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes(user + ":" + (password ?? ""))));
}
public Uri Server { get; }
/// <summary>"192.168.1.10:8080", "http://equipo:8080/fotos/" -> Uri del servidor y ruta inicial.</summary>
public static bool TryParseAddress(string text, out Uri server, out string path)
{
server = null!;
path = "/";
text = text.Trim();
if (text.Length == 0)
return false;
if (!text.Contains("://", StringComparison.Ordinal))
text = "http://" + text;
if (!Uri.TryCreate(text, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
return false;
server = new Uri(uri.GetLeftPart(UriPartial.Authority) + "/");
path = Uri.UnescapeDataString(uri.AbsolutePath);
return true;
}
public static string Combine(string folder, string name) => folder.TrimEnd('/') + "/" + name;
public static string Parent(string path)
{
string trimmed = path.TrimEnd('/');
int cut = trimmed.LastIndexOf('/');
return cut <= 0 ? "/" : trimmed[..cut];
}
private static string Encode(string path, bool collection)
{
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries).Select(Uri.EscapeDataString);
string encoded = "/" + string.Join('/', parts);
return collection && encoded != "/" ? encoded + "/" : encoded;
}
/// <summary>Contenido de una carpeta (sin la propia carpeta), carpetas primero.</summary>
public async Task<List<DavItem>> ListAsync(string folder, CancellationToken ct = default)
{
const string body = "<?xml version=\"1.0\" encoding=\"utf-8\"?><D:propfind xmlns:D=\"DAV:\"><D:prop>" +
"<D:resourcetype/><D:getcontentlength/><D:getlastmodified/></D:prop></D:propfind>";
using var request = new HttpRequestMessage(PropFind, Encode(folder, collection: true))
{
Content = new StringContent(body, Encoding.UTF8, "application/xml"),
};
request.Headers.Add("Depth", "1");
using var response = await _http.SendAsync(request, ct);
await EnsureAsync(response, 207);
var xml = await response.Content.ReadAsByteArrayAsync(ct);
string self = Encode(folder, collection: true);
return ParseMultiStatus(xml)
.Where(i => !SamePath(i.Path, folder))
.OrderBy(i => i.IsCollection ? 0 : 1)
.ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static bool SamePath(string a, string b) =>
string.Equals(a.TrimEnd('/'), b.TrimEnd('/'), StringComparison.Ordinal);
internal static List<DavItem> ParseMultiStatus(byte[] xml)
{
var items = new List<DavItem>();
var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, IgnoreWhitespace = true };
using var reader = XmlReader.Create(new MemoryStream(xml), settings);
string? href = null;
bool collection = false;
long size = 0;
DateTime? modified = null;
string? current = null;
while (reader.Read())
{
if (reader.NodeType == XmlNodeType.Element && reader.NamespaceURI == "DAV:")
{
switch (reader.LocalName)
{
case "response":
href = null; collection = false; size = 0; modified = null;
break;
case "collection":
collection = true;
break;
case "href" or "getcontentlength" or "getlastmodified":
current = reader.LocalName;
break;
}
}
else if (reader.NodeType == XmlNodeType.Text && current is not null)
{
switch (current)
{
case "href": href ??= reader.Value.Trim(); break;
case "getcontentlength":
long.TryParse(reader.Value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out size);
break;
case "getlastmodified":
if (DateTime.TryParse(reader.Value.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var t))
modified = t.ToLocalTime();
break;
}
current = null;
}
else if (reader.NodeType == XmlNodeType.EndElement && reader.NamespaceURI == "DAV:" && reader.LocalName == "response" && href is not null)
{
string path = HrefToPath(href);
string name = path.TrimEnd('/');
name = name[(name.LastIndexOf('/') + 1)..];
items.Add(new DavItem(name, path.Length > 1 ? path.TrimEnd('/') : "/", collection, size, modified));
}
}
return items;
}
/// <summary>href absoluto o relativo -> ruta decodificada.</summary>
private static string HrefToPath(string href)
{
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute) && absolute.Scheme.StartsWith("http", StringComparison.Ordinal))
href = absolute.AbsolutePath;
return PathRules.PercentDecode(href);
}
public async Task DownloadAsync(string path, Stream destination, IProgress<long>? progress = null, CancellationToken ct = default)
{
using var response = await _http.GetAsync(Encode(path, false), HttpCompletionOption.ResponseHeadersRead, ct);
await EnsureAsync(response, 200);
await using var source = await response.Content.ReadAsStreamAsync(ct);
await CopyAsync(source, destination, progress, ct);
}
public async Task UploadAsync(string path, Stream source, IProgress<long>? progress = null, CancellationToken ct = default)
{
using var content = new ProgressContent(source, progress);
using var response = await _http.PutAsync(Encode(path, false), content, ct);
await EnsureAsync(response, 201, 204, 200);
}
public async Task CreateFolderAsync(string path, CancellationToken ct = default)
{
using var response = await _http.SendAsync(new HttpRequestMessage(MkCol, Encode(path, true)), ct);
await EnsureAsync(response, 201, 405); // 405: ya existe
}
public async Task DeleteAsync(string path, bool collection, CancellationToken ct = default)
{
using var response = await _http.DeleteAsync(Encode(path, collection), ct);
await EnsureAsync(response, 204, 200);
}
public async Task MoveAsync(string from, string to, bool collection, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(Move, Encode(from, collection));
request.Headers.Add("Destination", new Uri(Server, Encode(to, collection)).ToString());
request.Headers.Add("Overwrite", "F");
using var response = await _http.SendAsync(request, ct);
await EnsureAsync(response, 201, 204);
}
/// <summary>Descarga un fichero o una carpeta entera a una carpeta local.</summary>
public async Task DownloadTreeAsync(DavItem item, string localFolder, IProgress<long>? progress = null, CancellationToken ct = default)
{
string target = Path.Combine(localFolder, SafeLocalName(item.Name));
if (!item.IsCollection)
{
string temp = target + ".part";
await using (var file = File.Create(temp))
await DownloadAsync(item.Path, file, progress, ct);
File.Move(temp, target, overwrite: true);
return;
}
Directory.CreateDirectory(target);
foreach (var child in await ListAsync(item.Path, ct))
await DownloadTreeAsync(child, target, progress, ct);
}
/// <summary>Sube un fichero o una carpeta local entera a una carpeta remota.</summary>
public async Task UploadTreeAsync(string localPath, string remoteFolder, IProgress<long>? progress = null, CancellationToken ct = default)
{
string name = Path.GetFileName(Path.TrimEndingDirectorySeparator(localPath));
string remote = Combine(remoteFolder, name);
if (File.Exists(localPath))
{
await using var file = File.OpenRead(localPath);
await UploadAsync(remote, file, progress, ct);
return;
}
await CreateFolderAsync(remote, ct);
foreach (var entry in Directory.EnumerateFileSystemEntries(localPath))
{
if (Path.GetFileName(entry).StartsWith('.'))
continue;
await UploadTreeAsync(entry, remote, progress, ct);
}
}
/// <summary>Un nombre remoto que se pueda guardar en este sistema.</summary>
private static string SafeLocalName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var clean = new string(name.Select(c => invalid.Contains(c) || c == ':' ? '_' : c).ToArray()).TrimEnd('.', ' ');
return clean.Length == 0 || clean is "." or ".." ? "_" : clean;
}
private static async Task CopyAsync(Stream source, Stream destination, IProgress<long>? progress, CancellationToken ct)
{
var buffer = new byte[65536];
int n;
while ((n = await source.ReadAsync(buffer, ct)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, n), ct);
progress?.Report(n);
}
}
private static async Task EnsureAsync(HttpResponseMessage response, params int[] ok)
{
int status = (int)response.StatusCode;
if (ok.Contains(status))
return;
string text = "";
try
{
text = await response.Content.ReadAsStringAsync();
if (text.Length > 200 || text.TrimStart().StartsWith('<'))
text = "";
}
catch (HttpRequestException) { }
throw new DavException(status, $"{status} {response.ReasonPhrase} {text}".Trim());
}
public void Dispose() => _http.Dispose();
/// <summary>Cuerpo de PUT que informa del progreso.</summary>
private sealed class ProgressContent(Stream source, IProgress<long>? progress) : HttpContent
{
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
await CopyAsync(source, stream, progress, CancellationToken.None);
protected override bool TryComputeLength(out long length)
{
if (source.CanSeek)
{
length = source.Length - source.Position;
return true;
}
length = 0;
return false;
}
}
}