using System.Globalization;
using System.Text;
using System.Xml;
using Sunfish.Config;
using Sunfish.Http;
using Sunfish.Shares;
namespace Sunfish.Web;
/// <summary>
/// WebDAV (clase 1 y LOCK simulado, se anuncia clase 2) sobre el mismo puerto y arbol que la web,
/// para montar los shares desde el Explorador, Finder o GVfs, y para que un Sunfish explore a otro.
/// GET, HEAD y PUT son los de la web. Los bloqueos devuelven tokens validos pero no bloquean
/// nada: el Explorador de Windows y Finder los exigen para escribir.
/// </summary>
public sealed partial class WebApp
{
private const string DavNs = "DAV:";
private const string AllowedMethods = "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK";
private const int MaxXmlBytes = 1 << 20;
private static bool IsDavMethod(string method) =>
method is "PROPFIND" or "PROPPATCH" or "MKCOL" or "DELETE" or "COPY" or "MOVE" or "LOCK" or "UNLOCK";
/// <summary>Un recurso WebDAV: real (fichero o carpeta de un share) o carpeta virtual.</summary>
private sealed record DavNode(List<string> Segments, ShareConfig? Share, IReadOnlyList<string> Relative,
string? Physical, IReadOnlyList<string> VirtualChildren)
{
public bool IsFile => Physical is not null && File.Exists(Physical);
public bool IsRealDirectory => Physical is not null && Directory.Exists(Physical);
public bool IsCollection => IsRealDirectory || (!IsFile && (VirtualChildren.Count > 0 || Segments.Count == 0));
public bool Exists => IsFile || IsCollection;
/// <summary>Se puede cambiar: dentro de un share, y no es su raiz.</summary>
public bool InsideShare => Share is not null && Relative.Count > 0;
}
private static DavNode NodeOf(ServerState st, List<string> segments)
{
var r = st.Tree.Resolve(segments);
string? physical = r.Share is null ? null : ShareFileSystem.TryGetPhysicalPath(r.Share, r.Relative);
return new DavNode(segments, r.Share, r.Relative, physical, r.VirtualChildren);
}
private static async Task DavOptionsAsync(HttpContext ctx)
{
ctx.Response.Headers["DAV"] = "1, 2";
ctx.Response.Headers["MS-Author-Via"] = "DAV";
ctx.Response.Headers["Allow"] = AllowedMethods;
await ctx.Response.StartAsync(0, ctx.Aborted);
}
private async Task DavAsync(HttpContext ctx, ServerState st, ClientProfile profile, List<string> segments, Resolution resolution)
{
var node = NodeOf(st, segments);
if (node.Share is { } share)
{
if (!await AuthorizeAsync(ctx, st, profile, share))
return;
if (!share.WebDav)
{
ctx.Response.Headers["Allow"] = "GET, HEAD, POST, PUT";
await SendTextAsync(ctx, 405, "WebDAV is disabled for this share");
return;
}
}
ctx.Response.Headers["DAV"] = "1, 2";
ctx.Response.Headers["MS-Author-Via"] = "DAV";
switch (ctx.Request.Method)
{
case "PROPFIND": await PropFindAsync(ctx, st, node); break;
case "PROPPATCH": await PropPatchAsync(ctx, node); break;
case "MKCOL": await MkColAsync(ctx, node); break;
case "DELETE": await DavDeleteAsync(ctx, node); break;
case "COPY": await CopyMoveAsync(ctx, st, node, move: false); break;
case "MOVE": await CopyMoveAsync(ctx, st, node, move: true); break;
case "LOCK": await LockAsync(ctx, node); break;
case "UNLOCK":
ctx.Response.StatusCode = 204;
await ctx.Response.StartAsync(0, ctx.Aborted);
break;
}
_ = resolution;
}
// ------------------------------------------------------------------ PROPFIND
private static readonly string[] AllProps =
["displayname", "resourcetype", "getcontentlength", "getcontenttype", "getlastmodified", "creationdate", "getetag", "supportedlock", "lockdiscovery"];
private async Task PropFindAsync(HttpContext ctx, ServerState st, DavNode node)
{
if (!node.Exists)
{
await SendTextAsync(ctx, 404, "Not found");
return;
}
string depth = (ctx.Request.Headers["Depth"] ?? "1").Trim();
if (depth.Equals("infinity", StringComparison.OrdinalIgnoreCase))
{
ctx.Response.StatusCode = 403;
await SendXmlAsync(ctx, 403, w =>
{
w.WriteStartElement("D", "error", DavNs);
w.WriteElementString("D", "propfind-finite-depth", DavNs, "");
w.WriteEndElement();
});
return;
}
// Sin cuerpo = allprop. Con <prop>, solo las pedidas; con <propname>, solo los nombres.
var (requested, namesOnly) = ParsePropFind(await ReadBodyAsync(ctx));
if (node.Share is { Listing: false } && node.IsRealDirectory && depth == "1")
depth = "0"; // sin Listing no se enumera el contenido
var entries = new List<(string Href, FileEntry Entry)>();
string baseHref = DavHref(node.Segments, node.IsCollection);
entries.Add((baseHref, EntryOf(node)));
if (depth == "1" && node.IsCollection)
{
foreach (var child in node.VirtualChildren)
entries.Add((DavHref([.. node.Segments, child], true), new FileEntry(child, true, 0, default, IsVirtual: true)));
if (node.IsRealDirectory)
{
foreach (var e in ShareFileSystem.List(node.Physical!))
{
if (node.VirtualChildren.Contains(e.Name, StringComparer.OrdinalIgnoreCase))
continue;
entries.Add((DavHref([.. node.Segments, e.Name], e.IsDirectory), e));
}
}
}
await SendXmlAsync(ctx, 207, w =>
{
w.WriteStartElement("D", "multistatus", DavNs);
foreach (var (href, entry) in entries)
WriteResponse(w, href, entry, requested, namesOnly);
w.WriteEndElement();
});
}
private static FileEntry EntryOf(DavNode node)
{
string name = node.Segments.Count == 0 ? "/" : node.Segments[^1];
if (node.IsFile)
{
var info = new FileInfo(node.Physical!);
return new FileEntry(name, false, info.Length, info.LastWriteTime);
}
if (node.IsRealDirectory)
return new FileEntry(name, true, 0, Directory.GetLastWriteTime(node.Physical!));
return new FileEntry(name, true, 0, default, IsVirtual: true);
}
private static void WriteResponse(XmlWriter w, string href, FileEntry e, List<(string Ns, string Name)>? requested, bool namesOnly)
{
var found = new List<(string Ns, string Name)>();
var missing = new List<(string Ns, string Name)>();
foreach (var p in requested ?? AllProps.Select(n => (DavNs, n)).ToList())
{
if (p.Ns == DavNs && AllProps.Contains(p.Name) && (p.Name != "getcontentlength" && p.Name != "getcontenttype" || !e.IsDirectory))
found.Add(p);
else
missing.Add(p);
}
w.WriteStartElement("D", "response", DavNs);
w.WriteElementString("D", "href", DavNs, href);
w.WriteStartElement("D", "propstat", DavNs);
w.WriteStartElement("D", "prop", DavNs);
foreach (var (_, name) in found)
{
w.WriteStartElement("D", name, DavNs);
if (!namesOnly)
WritePropValue(w, name, e);
w.WriteEndElement();
}
w.WriteEndElement();
w.WriteElementString("D", "status", DavNs, "HTTP/1.1 200 OK");
w.WriteEndElement();
if (missing.Count > 0)
{
w.WriteStartElement("D", "propstat", DavNs);
w.WriteStartElement("D", "prop", DavNs);
foreach (var (ns, name) in missing)
w.WriteElementString(name, ns, "");
w.WriteEndElement();
w.WriteElementString("D", "status", DavNs, "HTTP/1.1 404 Not Found");
w.WriteEndElement();
}
w.WriteEndElement();
}
private static void WritePropValue(XmlWriter w, string name, FileEntry e)
{
var modified = e.IsVirtual ? DateTime.UtcNow : e.Modified.ToUniversalTime();
switch (name)
{
case "displayname": w.WriteString(e.Name); break;
case "resourcetype":
if (e.IsDirectory)
w.WriteElementString("D", "collection", DavNs, "");
break;
case "getcontentlength": w.WriteString(e.Size.ToString(CultureInfo.InvariantCulture)); break;
case "getcontenttype": w.WriteString(MimeTypes.For(e.Name)); break;
case "getlastmodified": w.WriteString(HttpResponse.FormatDate(modified)); break;
case "creationdate": w.WriteString(modified.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture)); break;
case "getetag": w.WriteString($"\"{modified.Ticks:x}-{e.Size:x}\""); break;
case "supportedlock":
w.WriteStartElement("D", "lockentry", DavNs);
w.WriteStartElement("D", "lockscope", DavNs);
w.WriteElementString("D", "exclusive", DavNs, "");
w.WriteEndElement();
w.WriteStartElement("D", "locktype", DavNs);
w.WriteElementString("D", "write", DavNs, "");
w.WriteEndElement();
w.WriteEndElement();
break;
}
}
/// <summary>Propiedades pedidas (null = todas) y si solo se quieren los nombres.</summary>
internal static (List<(string Ns, string Name)>? Props, bool NamesOnly) ParsePropFind(byte[] body)
{
if (body.Length == 0)
return (null, false);
List<(string, string)>? props = null;
bool namesOnly = false;
bool inProp = false;
int propDepth = 0;
using var reader = XmlReader.Create(new MemoryStream(body), SafeXml);
try
{
while (reader.Read())
{
if (reader.NodeType == XmlNodeType.Element)
{
if (inProp && reader.Depth == propDepth + 1)
props!.Add((reader.NamespaceURI, reader.LocalName));
else if (reader.NamespaceURI == DavNs && reader.LocalName == "prop")
{
inProp = true;
propDepth = reader.Depth;
props = new();
}
else if (reader.NamespaceURI == DavNs && reader.LocalName == "propname")
namesOnly = true;
}
}
}
catch (XmlException)
{
return (null, false);
}
return (namesOnly ? null : props, namesOnly);
}
// ------------------------------------------------------------------ PROPPATCH
/// <summary>Se acepta y se responde que se aplico (el Explorador de Windows insiste en fijar sus atributos).</summary>
private async Task PropPatchAsync(HttpContext ctx, DavNode node)
{
if (!node.Exists)
{
await SendTextAsync(ctx, 404, "Not found");
return;
}
var body = await ReadBodyAsync(ctx);
var names = new List<(string Ns, string Name)>();
try
{
using var reader = XmlReader.Create(new MemoryStream(body), SafeXml);
int propDepth = -1;
while (reader.Read())
{
if (reader.NodeType != XmlNodeType.Element)
continue;
if (reader.NamespaceURI == DavNs && reader.LocalName == "prop")
propDepth = reader.Depth;
else if (propDepth >= 0 && reader.Depth == propDepth + 1)
names.Add((reader.NamespaceURI, reader.LocalName));
}
}
catch (XmlException)
{
await SendTextAsync(ctx, 400, "Bad XML");
return;
}
string href = DavHref(node.Segments, node.IsCollection);
await SendXmlAsync(ctx, 207, w =>
{
w.WriteStartElement("D", "multistatus", DavNs);
w.WriteStartElement("D", "response", DavNs);
w.WriteElementString("D", "href", DavNs, href);
w.WriteStartElement("D", "propstat", DavNs);
w.WriteStartElement("D", "prop", DavNs);
foreach (var (ns, name) in names)
w.WriteElementString(name, ns, "");
w.WriteEndElement();
w.WriteElementString("D", "status", DavNs, "HTTP/1.1 200 OK");
w.WriteEndElement();
w.WriteEndElement();
w.WriteEndElement();
});
}
// ------------------------------------------------------------------ cambios
private async Task MkColAsync(HttpContext ctx, DavNode node)
{
if (ctx.Request.Body is RequestBodyStream body && !body.Completed && ctx.Request.Headers["Content-Length"] is not (null or "0"))
{
await SendTextAsync(ctx, 415, "MKCOL with a body is not supported");
return;
}
if (node.Exists)
{
await SendTextAsync(ctx, 405, "Already exists");
return;
}
if (!node.InsideShare || !node.Share!.Upload)
{
await SendTextAsync(ctx, 403, Strings.ErrorText(403));
return;
}
string? parent = Path.GetDirectoryName(node.Physical!);
if (node.Physical is null || parent is null || !Directory.Exists(parent))
{
await SendTextAsync(ctx, 409, "Parent does not exist");
return;
}
var result = FileOperations.CreateFolder(parent, node.Segments[^1]);
if (result != FileOpResult.Ok)
{
await SendTextAsync(ctx, result == FileOpResult.InvalidName ? 400 : 409, Strings.OpResult(result, node.Segments[^1]));
return;
}
log.Info($"{ctx.Request.RemoteEndPoint?.Address} DAV MKCOL /{string.Join('/', node.Segments)}");
ctx.Response.StatusCode = 201;
await ctx.Response.StartAsync(0, ctx.Aborted);
}
private async Task DavDeleteAsync(HttpContext ctx, DavNode node)
{
if (!node.Exists)
{
await SendTextAsync(ctx, 404, "Not found");
return;
}
if (!node.InsideShare || !node.Share!.Delete || !(node.IsFile || node.IsRealDirectory))
{
await SendTextAsync(ctx, 403, Strings.ErrorText(403));
return;
}
var result = FileOperations.Delete(node.Physical!);
if (result != FileOpResult.Ok)
{
await SendTextAsync(ctx, 409, Strings.OpResult(result, node.Segments[^1]));
return;
}
// FileOperations.Delete ya se lleva el AppleDouble ("._nombre") del fichero.
log.Info($"{ctx.Request.RemoteEndPoint?.Address} DAV DELETE /{string.Join('/', node.Segments)}");
ctx.Response.StatusCode = 204;
await ctx.Response.StartAsync(0, ctx.Aborted);
}
private async Task CopyMoveAsync(HttpContext ctx, ServerState st, DavNode source, bool move)
{
if (!source.Exists)
{
await SendTextAsync(ctx, 404, "Not found");
return;
}
if (!TryDestination(ctx.Request.Headers["Destination"], out var destSegments))
{
await SendTextAsync(ctx, 400, "Bad Destination");
return;
}
var dest = NodeOf(st, destSegments);
if (dest.Share != source.Share)
{
await SendTextAsync(ctx, 502, "Destination is in another share");
return;
}
var share = source.Share;
bool allowed = share is not null && source.InsideShare && dest.InsideShare &&
(source.IsFile || source.IsRealDirectory) &&
(move ? share.Rename : share.Upload);
if (!allowed || dest.Physical is null)
{
await SendTextAsync(ctx, 403, Strings.ErrorText(403));
return;
}
string? destParent = Path.GetDirectoryName(dest.Physical);
string name = dest.Segments[^1];
bool nameOk = FileOperations.IsCreatableName(name) || (share!.WebDav && FileOperations.IsMacMetadataName(name));
if (destParent is null || !Directory.Exists(destParent) || !nameOk)
{
await SendTextAsync(ctx, 409, "Bad destination");
return;
}
bool overwriteHeader = !string.Equals(ctx.Request.Headers["Overwrite"]?.Trim(), "F", StringComparison.OrdinalIgnoreCase);
bool existed = dest.Exists;
if (existed)
{
if (!overwriteHeader)
{
await SendTextAsync(ctx, 412, "Destination exists");
return;
}
if (!share!.Delete || string.Equals(source.Physical, dest.Physical, StringComparison.OrdinalIgnoreCase))
{
await SendTextAsync(ctx, 403, Strings.ErrorText(403));
return;
}
FileOperations.Delete(dest.Physical);
}
FileOpResult result;
if (move)
{
result = MoveIncludingMacMetadata(source.Physical!, dest.Physical);
}
else
{
result = CopyRecursive(source.Physical!, dest.Physical);
}
if (result != FileOpResult.Ok)
{
await SendTextAsync(ctx, 409, Strings.OpResult(result, name));
return;
}
log.Info($"{ctx.Request.RemoteEndPoint?.Address} DAV {(move ? "MOVE" : "COPY")} /{string.Join('/', source.Segments)} -> /{string.Join('/', dest.Segments)}");
ctx.Response.StatusCode = existed ? 204 : 201;
await ctx.Response.StartAsync(0, ctx.Aborted);
}
/// <summary>Mueve sin la restriccion de nombres con punto (Finder renombra sus ._ temporales).</summary>
private static FileOpResult MoveIncludingMacMetadata(string source, string target)
{
string name = Path.GetFileName(target);
if (FileOperations.IsCreatableName(name))
return FileOperations.Move(source, target);
try
{
if (File.Exists(source))
File.Move(source, target);
else
Directory.Move(source, target);
return FileOpResult.Ok;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
return FileOpResult.Failed;
}
}
private static FileOpResult CopyRecursive(string source, string target)
{
try
{
if (File.Exists(source))
{
File.Copy(source, target, overwrite: false);
return FileOpResult.Ok;
}
if (target.StartsWith(Path.TrimEndingDirectorySeparator(source) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
return FileOpResult.Conflict; // dentro de si misma
Directory.CreateDirectory(target);
foreach (var info in new DirectoryInfo(source).EnumerateFileSystemInfos())
{
if (info.LinkTarget is not null)
continue; // los enlaces no se siguen
var r = CopyRecursive(info.FullName, Path.Combine(target, info.Name));
if (r != FileOpResult.Ok)
return r;
}
return FileOpResult.Ok;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
return FileOpResult.Failed;
}
}
/// <summary>Destination: URL absoluta o ruta; se queda la ruta y se valida como cualquier otra.</summary>
internal static bool TryDestination(string? header, out List<string> segments)
{
segments = [];
if (string.IsNullOrWhiteSpace(header))
return false;
var (path, _) = HttpRequestReader.SplitTarget(header.Trim());
if (!PathRules.TrySplitUrlPath(path, out segments, out _))
return false;
return segments.Count == 0 || !segments[0].Equals(LocationPath.Reserved, StringComparison.OrdinalIgnoreCase);
}
// ------------------------------------------------------------------ LOCK
private async Task LockAsync(HttpContext ctx, DavNode node)
{
await ReadBodyAsync(ctx);
int status = 200;
if (!node.Exists)
{
// Bloquear un recurso que no existe crea un fichero vacio (lo hacen el Explorador y Finder).
if (!node.InsideShare || !node.Share!.Upload || node.Physical is null ||
Path.GetDirectoryName(node.Physical) is not { } parent || !Directory.Exists(parent))
{
await SendTextAsync(ctx, 409, "Cannot lock here");
return;
}
var created = await FileOperations.SaveAsync(parent, node.Segments[^1], Stream.Null, false, ctx.Aborted,
allowMacMetadata: FileOperations.IsMacMetadataName(node.Segments[^1]));
if (created != FileOpResult.Ok)
{
await SendTextAsync(ctx, 409, Strings.OpResult(created, node.Segments[^1]));
return;
}
status = 201;
}
string token = ctx.Request.Headers["If"] is { } ifHeader && ifHeader.Contains("opaquelocktoken:", StringComparison.Ordinal)
? ifHeader[(ifHeader.IndexOf("opaquelocktoken:", StringComparison.Ordinal))..].TrimEnd(')', '>', ' ')
: "opaquelocktoken:" + Guid.NewGuid();
string href = DavHref(node.Segments, node.IsCollection);
ctx.Response.Headers["Lock-Token"] = $"<{token}>";
await SendXmlAsync(ctx, status, w =>
{
w.WriteStartElement("D", "prop", DavNs);
w.WriteStartElement("D", "lockdiscovery", DavNs);
w.WriteStartElement("D", "activelock", DavNs);
w.WriteStartElement("D", "locktype", DavNs);
w.WriteElementString("D", "write", DavNs, "");
w.WriteEndElement();
w.WriteStartElement("D", "lockscope", DavNs);
w.WriteElementString("D", "exclusive", DavNs, "");
w.WriteEndElement();
w.WriteElementString("D", "depth", DavNs, "0");
w.WriteElementString("D", "timeout", DavNs, "Second-3600");
w.WriteStartElement("D", "locktoken", DavNs);
w.WriteElementString("D", "href", DavNs, token);
w.WriteEndElement();
w.WriteStartElement("D", "lockroot", DavNs);
w.WriteElementString("D", "href", DavNs, href);
w.WriteEndElement();
w.WriteEndElement();
w.WriteEndElement();
w.WriteEndElement();
});
}
// ------------------------------------------------------------------ utilidades
private static readonly XmlReaderSettings SafeXml = new()
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
IgnoreComments = true,
IgnoreWhitespace = true,
MaxCharactersInDocument = MaxXmlBytes,
};
/// <summary>"/fotos/a%20b/" (las colecciones acaban en '/').</summary>
private static string DavHref(IEnumerable<string> segments, bool collection)
{
string path = "/" + string.Join('/', segments.Select(HtmlBuilder.UrlSegment));
return collection && path != "/" ? path + "/" : path;
}
private static async Task<byte[]> ReadBodyAsync(HttpContext ctx)
{
var ms = new MemoryStream();
var buffer = new byte[8192];
int n;
while ((n = await ctx.Request.Body.ReadAsync(buffer, ctx.Aborted)) > 0)
{
ms.Write(buffer, 0, n);
if (ms.Length > MaxXmlBytes)
throw new InvalidDataException("XML body too large");
}
return ms.ToArray();
}
private static async Task SendXmlAsync(HttpContext ctx, int status, Action<XmlWriter> write)
{
var ms = new MemoryStream();
using (var w = XmlWriter.Create(ms, new XmlWriterSettings { Encoding = new UTF8Encoding(false) }))
{
w.WriteStartDocument();
write(w);
w.WriteEndDocument();
}
ctx.Response.StatusCode = status;
ctx.Response.Headers["Cache-Control"] = "no-cache";
await ctx.Response.SendAsync(ms.ToArray(), "application/xml; charset=utf-8", ctx.Aborted);
}
}