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);
}