using System.Globalization;
using System.Net;
using System.Text;
namespace Sunfish.Http;
/// <summary>
/// Lee peticiones de una conexion. Es tolerante a proposito con clientes antiguos: acepta lineas
/// acabadas solo en LF, HTTP/0.9, HTTP/1.0 sin Host, lineas en blanco antes de la peticion,
/// cabeceras partidas en varias lineas y lineas de cabecera que no se entienden (se ignoran).
/// </summary>
internal sealed class HttpRequestReader
{
public const int MaxLineLength = 8192;
public const int MaxHeaderBytes = 16384;
public const int MaxHeaderCount = 100;
private readonly Stream _stream;
private readonly byte[] _buffer = new byte[16384];
private int _pos;
private int _len;
private int _headerBytes;
public HttpRequestReader(Stream stream) => _stream = stream;
public IPEndPoint? Remote { get; init; }
public IPEndPoint? Local { get; init; }
/// <summary>
/// Lee la siguiente peticion. Devuelve null si la conexion se cierra limpiamente antes de
/// empezar otra. Lanza <see cref="HttpParseException"/> si esta mal formada.
/// </summary>
public async Task<HttpRequest?> ReadRequestAsync(Func<Task> sendContinue, CancellationToken ct)
{
_headerBytes = 0;
string? line;
// Lineas en blanco sueltas antes de la peticion (restos de un POST anterior en clientes viejos).
do
{
line = await ReadLineAsync(ct);
if (line is null)
return null;
} while (line.Length == 0);
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2 || parts.Length > 3)
throw new HttpParseException(400, "Bad request line");
string method = parts[0].ToUpperInvariant();
int major = 0, minor = 9;
if (parts.Length == 3)
{
if (!TryParseVersion(parts[2], out major, out minor))
throw new HttpParseException(400, "Bad HTTP version");
if (major > 1)
throw new HttpParseException(505, "HTTP version not supported");
}
else if (method != "GET")
{
throw new HttpParseException(400, "HTTP/0.9 only allows GET");
}
var (path, query) = SplitTarget(parts[1]);
var request = new HttpRequest
{
Method = method,
Path = path,
QueryString = query,
VersionMajor = major,
VersionMinor = minor,
RemoteEndPoint = Remote,
LocalEndPoint = Local,
};
if (major == 0)
return request; // HTTP/0.9: no hay cabeceras
while (true)
{
line = await ReadLineAsync(ct) ?? throw new HttpParseException(400, "Connection closed in headers");
if (line.Length == 0)
break;
if (line[0] == ' ' || line[0] == '\t')
{
request.Headers.AppendToLast(line.Trim());
continue;
}
int colon = line.IndexOf(':');
if (colon <= 0)
continue;
if (request.Headers.All.Count() >= MaxHeaderCount)
throw new HttpParseException(431, "Too many headers");
request.Headers.Add(line[..colon].Trim(), line[(colon + 1)..].Trim());
}
request.Body = CreateBody(request, sendContinue);
return request;
}
private Stream CreateBody(HttpRequest request, Func<Task> sendContinue)
{
bool expectContinue = request.IsHttp11OrLater &&
string.Equals(request.Headers["Expect"], "100-continue", StringComparison.OrdinalIgnoreCase);
Func<Task>? onFirstRead = expectContinue ? sendContinue : null;
if (request.Headers.HasToken("Transfer-Encoding", "chunked"))
return new ChunkedReadStream(this, onFirstRead);
string? length = request.Headers["Content-Length"];
if (length is not null)
{
if (!long.TryParse(length, NumberStyles.None, CultureInfo.InvariantCulture, out long value))
throw new HttpParseException(400, "Bad Content-Length");
if (value > 0)
return new ContentLengthReadStream(this, value, onFirstRead);
}
return Stream.Null;
}
/// <summary>
/// "/a%20b?x=1" o "http://host/a?x" -> ("/a%20b", "x=1"). Los bytes no ASCII que algunos
/// clientes envian sin codificar se pasan a %XX para decodificarlos despues como el resto.
/// </summary>
internal static (string Path, string Query) SplitTarget(string target)
{
int hash = target.IndexOf('#');
if (hash >= 0)
target = target[..hash];
if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
target.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
int start = target.IndexOf("//", StringComparison.Ordinal) + 2;
int slash = target.IndexOf('/', start);
target = slash < 0 ? "/" : target[slash..];
}
if (target.Length == 0 || target[0] != '/')
target = "/" + target;
var sb = new StringBuilder(target.Length);
foreach (char c in target)
{
if (c >= 0x80 || c == ' ')
sb.Append('%').Append(((int)c).ToString("X2", CultureInfo.InvariantCulture));
else
sb.Append(c);
}
string encoded = sb.ToString();
int q = encoded.IndexOf('?');
return q < 0 ? (encoded, "") : (encoded[..q], encoded[(q + 1)..]);
}
private static bool TryParseVersion(string text, out int major, out int minor)
{
major = minor = 0;
if (!text.StartsWith("HTTP/", StringComparison.OrdinalIgnoreCase))
return false;
var nums = text[5..].Split('.');
return nums.Length == 2 &&
int.TryParse(nums[0], NumberStyles.None, CultureInfo.InvariantCulture, out major) &&
int.TryParse(nums[1], NumberStyles.None, CultureInfo.InvariantCulture, out minor);
}
/// <summary>
/// Lee una linea hasta LF (quitando el CR si lo hay), como Latin-1: cada byte es un caracter.
/// Devuelve null si la conexion se cierra sin datos.
/// </summary>
internal async ValueTask<string?> ReadLineAsync(CancellationToken ct, bool countAsHeader = true)
{
if (!countAsHeader)
_headerBytes = 0; // lineas de trozos del cuerpo: solo cuenta el limite de cada linea
var sb = new StringBuilder();
bool any = false;
while (true)
{
if (_pos >= _len)
{
if (!await FillAsync(ct))
{
if (!any)
return null;
throw new HttpParseException(400, "Connection closed in line");
}
}
any = true;
int lf = Array.IndexOf(_buffer, (byte)'\n', _pos, _len - _pos);
int end = lf < 0 ? _len : lf;
sb.Append(Encoding.Latin1.GetString(_buffer, _pos, end - _pos));
_headerBytes += end - _pos + (lf < 0 ? 0 : 1);
_pos = lf < 0 ? _len : lf + 1;
if (sb.Length > MaxLineLength)
throw new HttpParseException(414, "Line too long");
if (_headerBytes > MaxHeaderBytes)
throw new HttpParseException(431, "Headers too large");
if (lf >= 0)
{
if (sb.Length > 0 && sb[^1] == '\r')
sb.Length--;
return sb.ToString();
}
}
}
/// <summary>Lee datos del cuerpo: primero lo que quede en el bufer, despues del socket.</summary>
internal async ValueTask<int> ReadRawAsync(Memory<byte> destination, CancellationToken ct)
{
if (_pos < _len)
{
int n = Math.Min(destination.Length, _len - _pos);
_buffer.AsMemory(_pos, n).CopyTo(destination);
_pos += n;
return n;
}
return await _stream.ReadAsync(destination, ct);
}
private async ValueTask<bool> FillAsync(CancellationToken ct)
{
_pos = 0;
_len = await _stream.ReadAsync(_buffer, ct);
return _len > 0;
}
}