Newer
Older
Sunfish / src / Sunfish.Core / Web / WebApp.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 32 KB 3.0
using System.Globalization;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;
using Sunfish.Auth;
using Sunfish.Config;
using Sunfish.Http;
using Sunfish.Log;
using Sunfish.Shares;

namespace Sunfish.Web;

/// <summary>Configuracion vigente para atender peticiones; se sustituye entera al recargar.</summary>
public sealed class ServerState(SunfishConfig config)
{
    public SunfishConfig Config { get; } = config;
    public ShareTree Tree { get; } = new(config.Shares);
}

/// <summary>
/// La web: recursos internos, autodescarga y los shares (indice, navegador de directorios,
/// descargas, ZIP, subidas y cambios). Los cambios llegan por formularios POST con token
/// anti-CSRF; la subida moderna usa PUT por trozos (un PUT de otra web necesitaria permiso CORS,
/// que nunca se da). Los shares con Users exigen HTTP Basic.
/// </summary>
public sealed partial class WebApp(Func<ServerState> state, ServerLog log, Authenticator auth, Host.SunfishHost? host = null)
{
    private const string ListParameter = "$list";
    private const int MaxFormBytes = 65536;

    private readonly Authenticator _auth = auth;
    private readonly CsrfTokens _tokens = new();

    /// <summary>Contexto de una peticion ya resuelta contra un share.</summary>
    private sealed record ShareRequest(HttpContext Ctx, ServerState State, ClientProfile Profile,
        List<string> Segments, ShareConfig Share, IReadOnlyList<string> VirtualChildren)
    {
        /// <summary>Ruta de la carpeta pedida ("/fotos/2026"); identifica los tokens anti-CSRF.</summary>
        public string FolderKey => "/" + string.Join('/', Segments);

        /// <summary>Ruta para el registro de un elemento de la carpeta.</summary>
        public string ItemPath(string name) => "/" + string.Join('/', Segments.Append(name));

        /// <summary>Ruta pedida tal cual (PUT apunta al fichero, no a la carpeta).</summary>
        public string RequestPath => FolderKey;
    }

    public async Task HandleAsync(HttpContext ctx)
    {
        try
        {
            await RouteAsync(ctx, state());
        }
        finally
        {
            var r = ctx.Request;
            log.Info($"{r.RemoteEndPoint?.Address} {r.Method} {r.Path}{(r.QueryString.Length > 0 ? "?" + r.QueryString : "")} {ctx.Response.StatusCode} {ctx.Response.BytesSent}");
        }
    }

    private async Task RouteAsync(HttpContext ctx, ServerState st)
    {
        var req = ctx.Request;
        var profile = ClientProfile.Detect(req.Headers["User-Agent"], req.GetQuery("p"));

        bool dav = IsDavMethod(req.Method);
        if (!dav && req.Method is not ("GET" or "HEAD" or "POST" or "PUT" or "OPTIONS"))
        {
            ctx.Response.Headers["Allow"] = AllowedMethods;
            await SendErrorAsync(ctx, 405, st, profile);
            return;
        }
        if (req.Method == "OPTIONS")
        {
            await DavOptionsAsync(ctx);
            return;
        }
        if (!PathRules.TrySplitUrlPath(req.Path, out var segments, out bool trailingSlash))
        {
            await SendErrorAsync(ctx, 400, st, profile);
            return;
        }

        if (segments.Count > 0 && segments[0].Equals(LocationPath.Reserved, StringComparison.OrdinalIgnoreCase))
        {
            await InternalAsync(ctx, segments, st, profile);
            return;
        }

        var resolution = st.Tree.Resolve(segments);
        if (dav)
        {
            await DavAsync(ctx, st, profile, segments, resolution);
            return;
        }
        if (resolution.Share is { } share)
        {
            if (!await AuthorizeAsync(ctx, st, profile, share))
                return;
            var sr = new ShareRequest(ctx, st, profile, segments, share, resolution.VirtualChildren);
            string? physical = ShareFileSystem.TryGetPhysicalPath(share, resolution.Relative, aliases: share.ShortNames && req.Method != "PUT");

            if (req.Method == "PUT")
            {
                await PutAsync(sr, physical, trailingSlash);
                return;
            }
            if (physical is not null && Directory.Exists(physical))
            {
                if (!trailingSlash)
                {
                    await RedirectToFolderAsync(ctx);
                    return;
                }
                if (req.Method == "POST")
                {
                    await PostAsync(sr, physical);
                    return;
                }
                await FolderGetAsync(sr, physical);
                return;
            }
            if (physical is not null && File.Exists(physical) && !trailingSlash && req.Method != "POST")
            {
                await SendShareFileAsync(sr, physical);
                return;
            }
        }
        else if (segments.Count == 0 && st.Config.Server.RootMenu && req.Method is "GET" or "HEAD")
        {
            await SendListingAsync(ctx, st, profile, segments, null, null, resolution.VirtualChildren, new FolderActions());
            return;
        }

        // Carpeta intermedia de una location mas especifica (sin carpeta real que la respalde).
        if (resolution.VirtualChildren.Count > 0 && segments.Count > 0 && req.Method is "GET" or "HEAD")
        {
            if (!trailingSlash)
                await RedirectToFolderAsync(ctx);
            else
                await SendListingAsync(ctx, st, profile, segments, null, null, resolution.VirtualChildren, new FolderActions());
            return;
        }

        if (segments.Count == 1 && segments[0].Equals("favicon.ico", StringComparison.OrdinalIgnoreCase))
        {
            await SendResourceAsync(ctx, "favicon.ico");
            return;
        }
        await SendErrorAsync(ctx, req.Method is "POST" or "PUT" ? 403 : 404, st, profile);
    }

    // ------------------------------------------------------------------ autenticacion

    private async Task<bool> AuthorizeAsync(HttpContext ctx, ServerState st, ClientProfile profile, ShareConfig share)
    {
        if (share.Users.Count == 0)
            return true;
        Authenticator.TryParseBasic(ctx.Request.Headers["Authorization"], out var user, out var password);
        var result = _auth.Check(st.Config.Users, share.Users,
            user.Length > 0 ? user : null, user.Length > 0 ? password : null, ctx.Request.RemoteEndPoint?.Address);
        if (result == AuthResult.Ok)
            return true;
        if (result == AuthResult.Blocked)
        {
            ctx.Response.Headers["Retry-After"] = "60";
            await SendErrorAsync(ctx, 429, st, profile);
            return false;
        }
        if (result == AuthResult.Wrong)
            log.Warning($"{ctx.Request.RemoteEndPoint?.Address}: auth failed for '{user}' ({share.Name})");
        ctx.Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{AsciiOnly(st.Config.Server.Name)}\", charset=\"UTF-8\"";
        await SendErrorAsync(ctx, 401, st, profile);
        return false;
    }

    private static string AsciiOnly(string text)
    {
        var sb = new StringBuilder();
        foreach (char c in text)
            sb.Append(c is >= ' ' and < (char)0x7F and not ('"' or '\\') ? c : '_');
        return sb.ToString();
    }

    // ------------------------------------------------------------------ GET de carpetas

    private async Task FolderGetAsync(ShareRequest sr, string physical)
    {
        var (ctx, st, profile, share) = (sr.Ctx, sr.State, sr.Profile, sr.Share);
        var req = ctx.Request;
        var actions = ActionsFor(sr);

        if (req.GetQuery("$rename") is { } toRename)
        {
            await ConfirmPageAsync(sr, physical, toRename, share.Rename, isDelete: false, actions);
            return;
        }
        if (req.GetQuery("$delete") is { } toDelete)
        {
            await ConfirmPageAsync(sr, physical, toDelete, share.Delete, isDelete: true, actions);
            return;
        }
        if (req.HasQuery("$zip"))
        {
            if (!share.Zip)
                await SendErrorAsync(ctx, 403, st, profile);
            else
                await SendZipAsync(sr, physical);
            return;
        }
        if (!req.HasQuery(ListParameter) && share.Index.Length > 0)
        {
            string index = Path.Combine(physical, share.Index);
            if (File.Exists(index))
            {
                await SendFileAsync(ctx, index);
                return;
            }
        }
        if (!share.Listing)
        {
            await SendErrorAsync(ctx, 403, st, profile);
            return;
        }
        await SendListingAsync(ctx, st, profile, sr.Segments, share, physical, sr.VirtualChildren, actions);
    }

    private FolderActions ActionsFor(ShareRequest sr)
    {
        string folder = sr.FolderKey;
        return new FolderActions
        {
            Upload = sr.Share.Upload,
            Rename = sr.Share.Rename,
            Delete = sr.Share.Delete,
            Zip = sr.Share.Zip,
            Token = action => _tokens.Create(action, folder),
        };
    }

    private async Task ConfirmPageAsync(ShareRequest sr, string physical, string name, bool allowed, bool isDelete, FolderActions actions)
    {
        var (ctx, st, profile) = (sr.Ctx, sr.State, sr.Profile);
        if (!allowed)
        {
            await SendErrorAsync(ctx, 403, st, profile);
            return;
        }
        string? target = ExistingChild(sr, physical, name);
        if (target is null)
        {
            await SendErrorAsync(ctx, 404, st, profile);
            return;
        }
        var model = new DirectoryPage.Model { ServerName = st.Config.Server.Name, Segments = sr.Segments, Profile = profile, Actions = actions };
        var html = isDelete ? DirectoryPage.RenderDelete(model, name, Directory.Exists(target)) : DirectoryPage.RenderRename(model, name);
        ctx.Response.Headers["Cache-Control"] = "no-cache";
        await ctx.Response.SendAsync(html.ToBytes(), html.ContentType, ctx.Aborted);
    }

    /// <summary>Ruta real de un elemento de la carpeta que exista y no este tapado por una location.</summary>
    private static string? ExistingChild(ShareRequest sr, string physicalDirectory, string name)
    {
        if (!PathRules.IsValidSegment(name) || sr.VirtualChildren.Contains(name, StringComparer.OrdinalIgnoreCase))
            return null;
        string? path = ShareFileSystem.TryGetPhysicalPath(sr.Share, [.. RelativeOf(sr), name], aliases: sr.Share.ShortNames);
        return path is not null && (File.Exists(path) || Directory.Exists(path)) ? path : null;
    }

    private static IReadOnlyList<string> RelativeOf(ShareRequest sr) =>
        sr.Segments.Skip(LocationPath.Segments(sr.Share.Location).Length).ToList();

    // ------------------------------------------------------------------ POST: formularios

    private async Task PostAsync(ShareRequest sr, string physical)
    {
        var (ctx, st, profile) = (sr.Ctx, sr.State, sr.Profile);
        string? contentType = ctx.Request.Headers["Content-Type"];
        try
        {
            if (MultipartReader.GetBoundary(contentType) is { } boundary)
                await PostMultipartAsync(sr, physical, boundary);
            else if (contentType?.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) == true)
                await PostFormAsync(sr, physical);
            else
                await SendErrorAsync(ctx, 400, st, profile);
        }
        catch (InvalidDataException)
        {
            if (!ctx.Response.HeadersSent)
                await SendErrorAsync(ctx, 400, st, profile);
        }
    }

    /// <summary>Subida por formulario (RFC 1867): campos a y t primero, despues los ficheros.</summary>
    private async Task PostMultipartAsync(ShareRequest sr, string physical, string boundary)
    {
        var (ctx, st, profile, share) = (sr.Ctx, sr.State, sr.Profile, sr.Share);
        var reader = new MultipartReader(ctx.Request.Body, boundary);
        var fields = new Dictionary<string, string>(StringComparer.Ordinal);
        var errors = new List<string>();
        int saved = 0;

        while (await reader.NextAsync(ctx.Aborted) is { } section)
        {
            if (section.FileName is null)
            {
                fields[section.Name] = await MultipartReader.ReadFieldAsync(section, MaxFormBytes, ctx.Aborted);
                continue;
            }
            // Ningun fichero toca el disco sin accion, token y permiso.
            if (fields.GetValueOrDefault("a") != "upload" || !_tokens.Validate(fields.GetValueOrDefault("t"), "upload", sr.FolderKey))
            {
                await SendErrorAsync(ctx, 403, st, profile, Strings.BadToken);
                return;
            }
            if (!share.Upload)
            {
                await SendErrorAsync(ctx, 403, st, profile);
                return;
            }
            string name = FileOperations.CleanUploadName(section.FileName);
            if (name.Length == 0)
                continue; // campo de fichero sin fichero elegido
            var result = await FileOperations.SaveAsync(physical, name, section.Body, share.Overwrite, ctx.Aborted);
            if (result == FileOpResult.Ok)
            {
                saved++;
                string finalName = await UnpackMacAsync(sr, Path.Combine(physical, name));
                log.Info($"{ctx.Request.RemoteEndPoint?.Address} upload {sr.ItemPath(finalName)}");
            }
            else
            {
                errors.Add(Strings.OpResult(result, name));
            }
        }
        if (saved == 0 && errors.Count == 0 && fields.GetValueOrDefault("a") is { } action && action != "upload")
        {
            // Navegador que envia un formulario sin ficheros como multipart.
            await ApplyFormAsync(sr, physical, fields);
            return;
        }
        if (errors.Count > 0)
            await SendErrorAsync(ctx, 409, st, profile, string.Join(" ", errors));
        else
            await RedirectAfterPostAsync(sr);
    }

    private async Task PostFormAsync(ShareRequest sr, string physical)
    {
        var ms = new MemoryStream();
        var buffer = new byte[8192];
        int n;
        while ((n = await sr.Ctx.Request.Body.ReadAsync(buffer, sr.Ctx.Aborted)) > 0)
        {
            ms.Write(buffer, 0, n);
            if (ms.Length > MaxFormBytes)
                throw new InvalidDataException("Form too large");
        }
        // Las paginas clasicas son ISO-8859-1 y los navegadores envian en ese juego; ParseQuery
        // prueba UTF-8 y si no, Latin-1.
        var fields = HttpRequest.ParseQuery(Encoding.Latin1.GetString(ms.ToArray()));
        await ApplyFormAsync(sr, physical, fields);
    }

    private async Task ApplyFormAsync(ShareRequest sr, string physical, IReadOnlyDictionary<string, string> fields)
    {
        var (ctx, st, profile, share) = (sr.Ctx, sr.State, sr.Profile, sr.Share);
        string action = fields.GetValueOrDefault("a") ?? "";
        if (!_tokens.Validate(fields.GetValueOrDefault("t"), action, sr.FolderKey))
        {
            await SendErrorAsync(ctx, 403, st, profile, Strings.BadToken);
            return;
        }
        string name = (fields.GetValueOrDefault("name") ?? "").Trim();
        FileOpResult result;
        switch (action)
        {
            case "mkdir" when share.Upload:
                result = sr.VirtualChildren.Contains(name, StringComparer.OrdinalIgnoreCase)
                    ? FileOpResult.Exists
                    : FileOperations.CreateFolder(physical, name);
                break;
            case "rename" when share.Rename:
            {
                string to = (fields.GetValueOrDefault("to") ?? "").Trim();
                string? source = ExistingChild(sr, physical, name);
                result = source is null ? FileOpResult.NotFound
                    : sr.VirtualChildren.Contains(to, StringComparer.OrdinalIgnoreCase) ? FileOpResult.Exists
                    : FileOperations.Rename(source, to);
                if (result != FileOpResult.Ok)
                    name = result == FileOpResult.NotFound ? name : to;
                break;
            }
            case "delete" when share.Delete:
            {
                string? target = ExistingChild(sr, physical, name);
                result = target is null ? FileOpResult.NotFound : FileOperations.Delete(target);
                break;
            }
            default:
                await SendErrorAsync(ctx, 403, st, profile);
                return;
        }
        if (result != FileOpResult.Ok)
        {
            await SendErrorAsync(ctx, result == FileOpResult.NotFound ? 404 : 409, st, profile, Strings.OpResult(result, name));
            return;
        }
        log.Info($"{ctx.Request.RemoteEndPoint?.Address} {action} {sr.ItemPath(name)}");
        await RedirectAfterPostAsync(sr);
    }

    /// <summary>Vuelta a la carpeta tras un POST: 303 en HTTP/1.1, 302 en 1.0 (no conoce el 303).</summary>
    private static async Task RedirectAfterPostAsync(ShareRequest sr)
    {
        var ctx = sr.Ctx;
        string location = BaseUrl(ctx.Request) + DirectoryPage.UrlOf(sr.Segments) + DirectoryPage.ProfileQuery(sr.Profile, true);
        ctx.Response.StatusCode = ctx.Request.IsHttp11OrLater ? 303 : 302;
        ctx.Response.Headers["Location"] = location;
        string html = $"<HTML><BODY><A HREF=\"{location}\">{location}</A></BODY></HTML>";
        await ctx.Response.SendAsync(Encoding.ASCII.GetBytes(html), "text/html", ctx.Aborted);
    }

    // ------------------------------------------------------------------ PUT: subida moderna

    [GeneratedRegex("^[A-Za-z0-9]{8,64}$")]
    private static partial Regex UploadIdPattern();

    /// <summary>
    /// PUT sobre la URL del fichero. Con Content-Range y X-Sunfish-Upload se sube por trozos
    /// (los trozos deben llegar en orden); sin ellos, el cuerpo es el fichero entero.
    /// </summary>
    private async Task PutAsync(ShareRequest sr, string? physical, bool trailingSlash)
    {
        var (ctx, st, profile, share) = (sr.Ctx, sr.State, sr.Profile, sr.Share);
        var req = ctx.Request;
        var relative = RelativeOf(sr);
        if (trailingSlash || relative.Count == 0 || physical is null)
        {
            await SendTextAsync(ctx, 400, "Bad target");
            return;
        }
        string name = relative[^1];
        string? directory = Path.GetDirectoryName(physical);
        if (directory is null || !Directory.Exists(directory) || sr.VirtualChildren.Count > 0 && relative.Count == 0)
        {
            await SendTextAsync(ctx, 409, Strings.OpResult(FileOpResult.NotFound, name)); // 409: falta la carpeta (WebDAV)
            return;
        }
        bool exists = File.Exists(physical);
        // Los clientes WebDAV (Explorador, Finder) crean primero un fichero vacio y despues lo
        // rellenan con otro PUT: sustituir un fichero vacio no cuenta como sobrescribir.
        bool replaceable = share.Overwrite || (exists && new FileInfo(physical).Length == 0);
        if (!share.Upload || (exists && !replaceable))
        {
            await SendTextAsync(ctx, exists ? 409 : 403, exists ? Strings.OpResult(FileOpResult.Exists, name) : Strings.ErrorText(403));
            return;
        }
        // Finder escribe ._nombre y .DS_Store por WebDAV; se guardan (ocultos en los listados).
        bool macMetadata = share.WebDav && FileOperations.IsMacMetadataName(name);

        string? range = req.Headers["Content-Range"];
        string? id = req.Headers["X-Sunfish-Upload"];
        long? declared = long.TryParse(req.Headers["Content-Length"], NumberStyles.None, CultureInfo.InvariantCulture, out long cl) ? cl : null;

        if (range is null)
        {
            if (declared is long size && !FileOperations.HasSpace(directory, size))
            {
                await SendTextAsync(ctx, 507, Strings.OpResult(FileOpResult.NoSpace, name));
                return;
            }
            var whole = await FileOperations.SaveAsync(directory, name, req.Body, replaceable, ctx.Aborted, macMetadata);
            // Solo las subidas de la pagina (X-Sunfish-Upload) se decodifican; un PUT de WebDAV
            // guarda los bytes tal cual (es un gestor de ficheros copiando un fichero).
            if (whole == FileOpResult.Ok && id is not null)
                await UnpackMacAsync(sr, Path.Combine(directory, name));
            await PutResultAsync(sr, whole, name, completed: true, exists);
            return;
        }

        if (id is null || !UploadIdPattern().IsMatch(id) || !TryParseContentRange(range, out long start, out long end, out long total))
        {
            await SendTextAsync(ctx, 400, "Bad Content-Range or X-Sunfish-Upload");
            return;
        }
        if (declared is long partLength && partLength != end - start + 1)
        {
            await SendTextAsync(ctx, 400, "Content-Length does not match Content-Range");
            return;
        }
        if (start == 0 && !FileOperations.HasSpace(directory, total))
        {
            await SendTextAsync(ctx, 507, Strings.OpResult(FileOpResult.NoSpace, name));
            return;
        }
        var (result, completed) = await FileOperations.WritePartAsync(directory, name, id, start, total, req.Body, replaceable, ctx.Aborted);
        if (result == FileOpResult.Ok && completed)
            await UnpackMacAsync(sr, Path.Combine(directory, name));
        await PutResultAsync(sr, result, name, completed, exists);
    }

    private async Task PutResultAsync(ShareRequest sr, FileOpResult result, string name, bool completed, bool existed)
    {
        var ctx = sr.Ctx;
        if (result != FileOpResult.Ok)
        {
            int status = result switch
            {
                FileOpResult.InvalidName => 400,
                FileOpResult.NoSpace => 507,
                _ => 409,
            };
            await SendTextAsync(ctx, status, Strings.OpResult(result, name));
            return;
        }
        if (completed)
            log.Info($"{ctx.Request.RemoteEndPoint?.Address} upload {sr.RequestPath}");
        ctx.Response.StatusCode = completed ? (existed ? 204 : 201) : 202;
        await ctx.Response.StartAsync(0, ctx.Aborted);
    }

    /// <summary>"bytes 0-1048575/5000000".</summary>
    internal static bool TryParseContentRange(string header, out long start, out long end, out long total)
    {
        start = end = total = 0;
        header = header.Trim();
        if (!header.StartsWith("bytes ", StringComparison.OrdinalIgnoreCase))
            return false;
        var spec = header[6..].Split('/');
        if (spec.Length != 2)
            return false;
        var bounds = spec[0].Split('-');
        return bounds.Length == 2 &&
               long.TryParse(bounds[0], NumberStyles.None, CultureInfo.InvariantCulture, out start) &&
               long.TryParse(bounds[1], NumberStyles.None, CultureInfo.InvariantCulture, out end) &&
               long.TryParse(spec[1], NumberStyles.None, CultureInfo.InvariantCulture, out total) &&
               start <= end && end < total;
    }

    // ------------------------------------------------------------------ ZIP

    private async Task SendZipAsync(ShareRequest sr, string physical)
    {
        var (ctx, st, profile) = (sr.Ctx, sr.State, sr.Profile);
        var entries = ZipStreamWriter.Collect(physical)
            .Where(e => !sr.VirtualChildren.Contains(e.Name.Split('/')[0], StringComparer.OrdinalIgnoreCase))
            .ToList();
        var zip = new ZipStreamWriter(entries, preferUtf8: profile.IsModern);
        if (zip.TooLarge)
        {
            await SendErrorAsync(ctx, 413, st, profile, Strings.ZipTooLarge);
            return;
        }
        string folderName = sr.Segments.Count > 0 ? sr.Segments[^1] : st.Config.Server.Name;
        ctx.Response.Headers["Content-Type"] = "application/zip";
        ctx.Response.Headers["Content-Disposition"] = ContentDisposition(folderName + ".zip");
        var body = await ctx.Response.StartAsync(zip.TotalLength, ctx.Aborted);
        if (!ctx.Request.IsHead)
            await zip.WriteAsync(body, ctx.Aborted);
    }

    // ------------------------------------------------------------------ respuestas comunes

    private async Task InternalAsync(HttpContext ctx, List<string> segments, ServerState st, ClientProfile profile)
    {
        if (segments.Count >= 2 && segments[1] == "admin")
        {
            await AdminAsync(ctx, segments, st, profile);
            return;
        }
        if (ctx.Request.Method is "GET" or "HEAD")
        {
            if (segments.Count == 3 && segments[1] == "res")
            {
                await SendResourceAsync(ctx, segments[2]);
                return;
            }
            if (segments.Count == 2 && segments[1] == "download" && st.Config.Server.SelfDownload &&
                Environment.ProcessPath is { } exe && File.Exists(exe))
            {
                ctx.Response.Headers["Content-Disposition"] = ContentDisposition(Path.GetFileName(exe));
                await SendFileAsync(ctx, exe, MimeTypes.Default);
                return;
            }
        }
        await SendErrorAsync(ctx, 404, st, profile);
    }

    private static async Task SendListingAsync(HttpContext ctx, ServerState st, ClientProfile profile,
        IReadOnlyList<string> segments, ShareConfig? share, string? physical, IReadOnlyList<string> virtualChildren, FolderActions actions)
    {
        var items = new List<ListingItem>();
        foreach (var child in virtualChildren)
            items.Add(new ListingItem(new FileEntry(child, true, 0, default, IsVirtual: true), false));
        if (physical is not null && share is not null)
        {
            // DOS y Windows 3.1 ven alias 8.3; se calculan con todos los nombres de la carpeta
            // (tambien los ocultos), igual que al resolverlos.
            var aliases = profile.UsesShortNames && share.ShortNames
                ? ShortNames.Map(new DirectoryInfo(physical).EnumerateFileSystemInfos().Select(i => i.Name))
                : null;
            foreach (var entry in ShareFileSystem.List(physical))
            {
                // Una location mas especifica tapa a la carpeta real del mismo nombre.
                if (virtualChildren.Contains(entry.Name, StringComparer.OrdinalIgnoreCase))
                    continue;
                bool hasIndex = entry.IsDirectory && share.Index.Length > 0 &&
                                File.Exists(Path.Combine(physical, entry.Name, share.Index));
                bool macFork = share.Mac && !entry.IsDirectory && Mac.MacFiles.HasCompanion(Path.Combine(physical, entry.Name));
                items.Add(new ListingItem(entry, hasIndex, aliases?.GetValueOrDefault(entry.Name), macFork));
            }
        }
        string sort = ctx.Request.GetQuery("sort") is "s" or "d" ? ctx.Request.GetQuery("sort")! : "n";
        var html = DirectoryPage.Render(new DirectoryPage.Model
        {
            ServerName = st.Config.Server.Name,
            Segments = segments,
            Items = items,
            Profile = profile,
            Sort = sort,
            Actions = actions,
        });
        ctx.Response.Headers["Cache-Control"] = "no-cache";
        await ctx.Response.SendAsync(html.ToBytes(), html.ContentType, ctx.Aborted);
    }

    private static async Task SendFileAsync(HttpContext ctx, string physical, string? contentType = null)
    {
        var req = ctx.Request;
        var res = ctx.Response;
        var info = new FileInfo(physical);
        DateTime modified = TruncateToSeconds(info.LastWriteTimeUtc);

        if (req.HasQuery("dl"))
            res.Headers["Content-Disposition"] = ContentDisposition(info.Name);
        res.Headers["Content-Type"] = contentType ?? MimeTypes.For(info.Name);
        res.Headers["Last-Modified"] = HttpResponse.FormatDate(modified);
        res.Headers["Accept-Ranges"] = "bytes";

        if (TryParseHttpDate(req.Headers["If-Modified-Since"], out var since) && modified <= since)
        {
            res.StatusCode = 304;
            await res.StartAsync(null, ctx.Aborted);
            return;
        }

        long start = 0, length = info.Length;
        switch (ByteRange.TryParse(req.Headers["Range"], info.Length, out var range))
        {
            case ByteRange.ParseResult.Valid:
                res.StatusCode = 206;
                res.Headers["Content-Range"] = $"bytes {range.Start}-{range.End}/{info.Length}";
                start = range.Start;
                length = range.Length;
                break;
            case ByteRange.ParseResult.Unsatisfiable:
                res.StatusCode = 416;
                res.Headers["Content-Range"] = $"bytes */{info.Length}";
                res.Headers["Content-Type"] = null;
                await res.StartAsync(0, ctx.Aborted);
                return;
        }

        await using var file = new FileStream(physical, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete,
            bufferSize: 1, useAsync: true);
        var body = await res.StartAsync(length, ctx.Aborted);
        if (req.IsHead)
            return;
        file.Position = start;
        var buffer = new byte[65536];
        long remaining = length;
        while (remaining > 0)
        {
            int n = await file.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), ctx.Aborted);
            if (n == 0)
                break;
            await body.WriteAsync(buffer.AsMemory(0, n), ctx.Aborted);
            remaining -= n;
        }
        if (remaining > 0)
            throw new IOException("File shrank while sending"); // la conexion se cierra
    }

    private static async Task SendResourceAsync(HttpContext ctx, string name)
    {
        var data = WebResources.Get(name);
        if (data is null)
        {
            await SendTextAsync(ctx, 404, "404 Not Found");
            return;
        }
        ctx.Response.Headers["Cache-Control"] = "max-age=86400";
        await ctx.Response.SendAsync(data, MimeTypes.For(name), ctx.Aborted);
    }

    private static async Task SendTextAsync(HttpContext ctx, int status, string text)
    {
        ctx.Response.StatusCode = status;
        await ctx.Response.SendAsync(Encoding.UTF8.GetBytes(text), "text/plain; charset=utf-8", ctx.Aborted);
    }

    private static async Task SendErrorAsync(HttpContext ctx, int status, ServerState st, ClientProfile profile, string? detail = null)
    {
        ctx.Response.StatusCode = status;
        var html = DirectoryPage.RenderError(status, st.Config.Server.Name, profile, detail);
        await ctx.Response.SendAsync(html.ToBytes(), html.ContentType, ctx.Aborted);
    }

    /// <summary>Carpeta pedida sin barra final: redireccion con URL absoluta (la exige HTTP/1.0).</summary>
    private static async Task RedirectToFolderAsync(HttpContext ctx)
    {
        var req = ctx.Request;
        string location = BaseUrl(req) + req.Path + "/" + (req.QueryString.Length > 0 ? "?" + req.QueryString : "");
        ctx.Response.StatusCode = 301;
        ctx.Response.Headers["Location"] = location;
        string html = $"<HTML><BODY><A HREF=\"{location}\">{location}</A></BODY></HTML>";
        await ctx.Response.SendAsync(Encoding.ASCII.GetBytes(html), "text/html", ctx.Aborted);
    }

    internal static string BaseUrl(HttpRequest req)
    {
        string? host = req.Headers["Host"];
        if (string.IsNullOrWhiteSpace(host) && req.LocalEndPoint is { } local)
        {
            host = local.Address.AddressFamily == AddressFamily.InterNetworkV6
                ? $"[{local.Address}]:{local.Port}"
                : $"{local.Address}:{local.Port}";
        }
        return "http://" + (host ?? "localhost");
    }

    /// <summary>attachment con nombre ASCII (navegadores antiguos) y UTF-8 (RFC 5987).</summary>
    internal static string ContentDisposition(string name) =>
        $"attachment; filename=\"{AsciiOnly(name)}\"; filename*=UTF-8''{Uri.EscapeDataString(name)}";

    internal static bool TryParseHttpDate(string? text, out DateTime utc)
    {
        utc = default;
        if (string.IsNullOrWhiteSpace(text))
            return false;
        // Algunos navegadores antiguos anaden "; length=NNN" tras la fecha.
        int semi = text.IndexOf(';');
        if (semi >= 0)
            text = text[..semi];
        return DateTime.TryParse(text.Trim(), CultureInfo.InvariantCulture,
            DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out utc);
    }

    private static DateTime TruncateToSeconds(DateTime utc) =>
        new(utc.Ticks - utc.Ticks % TimeSpan.TicksPerSecond, DateTimeKind.Utc);
}