using System.Text;
namespace Sunfish.Web;
/// <summary>Una parte de un formulario multipart/form-data.</summary>
internal sealed class MultipartSection
{
public Dictionary<string, string> Headers { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Atributo name de Content-Disposition.</summary>
public string Name { get; set; } = "";
/// <summary>Nombre del fichero tal como llega (puede traer ruta); null si es un campo normal.</summary>
public string? FileName { get; set; }
public Stream Body { get; set; } = Stream.Null;
}
/// <summary>
/// Lee multipart/form-data en flujo, sin cargar los ficheros en memoria: cada parte se lee hasta
/// el siguiente delimitador, que puede llegar partido entre lecturas. Tolera cabeceras de parte
/// acabadas solo en LF.
/// </summary>
internal sealed class MultipartReader
{
private readonly Stream _stream;
private readonly byte[] _delimiter; // "\r\n--" + boundary
private byte[] _buffer = new byte[65536];
private int _start;
private int _end;
private bool _eof;
private bool _first = true;
private bool _finished;
private SectionStream? _current;
public MultipartReader(Stream stream, string boundary)
{
_stream = stream;
_delimiter = Encoding.ASCII.GetBytes("\r\n--" + boundary);
// El primer delimitador no lleva CRLF delante: se anade uno para tratarlos todos igual.
_buffer[0] = (byte)'\r';
_buffer[1] = (byte)'\n';
_end = 2;
}
/// <summary>boundary de "multipart/form-data; boundary=..." (con o sin comillas).</summary>
public static string? GetBoundary(string? contentType)
{
if (contentType is null || !contentType.TrimStart().StartsWith("multipart/form-data", StringComparison.OrdinalIgnoreCase))
return null;
foreach (var part in contentType.Split(';'))
{
var p = part.Trim();
if (p.StartsWith("boundary=", StringComparison.OrdinalIgnoreCase))
{
string value = p[9..].Trim().Trim('"');
return value.Length is > 0 and <= 200 ? value : null;
}
}
return null;
}
public async Task<MultipartSection?> NextAsync(CancellationToken ct)
{
if (_finished)
return null;
if (_current is not null)
{
// Lo que no se haya leido de la parte anterior se descarta.
var skip = new byte[16384];
while (await _current.ReadAsync(skip, ct) > 0) { }
}
else if (_first)
{
// Preambulo hasta el primer delimitador.
_first = false;
var skipper = new SectionStream(this);
var skip = new byte[16384];
while (await skipper.ReadAsync(skip, ct) > 0) { }
}
// Tras el delimitador: "--" es el final; si no, fin de linea y cabeceras de la parte.
if (!await EnsureAsync(2, ct))
throw new InvalidDataException("Truncated multipart");
if (_buffer[_start] == '-' && _buffer[_start + 1] == '-')
{
_finished = true;
return null;
}
await ReadLineAsync(ct); // resto de la linea del delimitador
var section = new MultipartSection();
while (true)
{
string line = await ReadLineAsync(ct);
if (line.Length == 0)
break;
int colon = line.IndexOf(':');
if (colon > 0)
section.Headers[line[..colon].Trim()] = line[(colon + 1)..].Trim();
}
if (section.Headers.TryGetValue("Content-Disposition", out var disposition))
{
section.Name = DispositionParameter(disposition, "name") ?? "";
section.FileName = DispositionParameter(disposition, "filename");
}
_current = new SectionStream(this);
section.Body = _current;
return section;
}
/// <summary>Lee un campo de texto pequeno (como mucho maxBytes).</summary>
public static async Task<string> ReadFieldAsync(MultipartSection section, int maxBytes, CancellationToken ct)
{
var ms = new MemoryStream();
var buffer = new byte[4096];
int n;
while ((n = await section.Body.ReadAsync(buffer, ct)) > 0)
{
ms.Write(buffer, 0, n);
if (ms.Length > maxBytes)
throw new InvalidDataException("Field too large");
}
return DecodeText(ms.ToArray());
}
/// <summary>UTF-8 si es valido; si no, Latin-1 (lo que envian las paginas clasicas).</summary>
internal static string DecodeText(byte[] bytes)
{
try
{
return new UTF8Encoding(false, true).GetString(bytes);
}
catch (DecoderFallbackException)
{
return Encoding.Latin1.GetString(bytes);
}
}
internal static string? DispositionParameter(string header, string name)
{
int i = 0;
while (i < header.Length)
{
int semi = header.IndexOf(';', i);
if (semi < 0)
return null;
i = semi + 1;
while (i < header.Length && header[i] == ' ')
i++;
int eq = header.IndexOf('=', i);
if (eq < 0)
return null;
string key = header[i..eq].Trim();
string value;
if (eq + 1 < header.Length && header[eq + 1] == '"')
{
// Valor entre comillas: puede contener ';'. IE antiguo no escapa las barras.
int close = header.IndexOf('"', eq + 2);
if (close < 0)
close = header.Length;
value = header[(eq + 2)..close];
i = close + 1;
}
else
{
int next = header.IndexOf(';', eq);
value = (next < 0 ? header[(eq + 1)..] : header[(eq + 1)..next]).Trim();
i = next < 0 ? header.Length : next;
}
if (key.Equals(name, StringComparison.OrdinalIgnoreCase))
return value;
}
return null;
}
private async Task<string> ReadLineAsync(CancellationToken ct)
{
var bytes = new List<byte>();
while (true)
{
if (_start >= _end && !await FillAsync(ct))
throw new InvalidDataException("Truncated multipart headers");
byte b = _buffer[_start++];
if (b == '\n')
break;
bytes.Add(b);
if (bytes.Count > 8192)
throw new InvalidDataException("Multipart header too long");
}
if (bytes.Count > 0 && bytes[^1] == '\r')
bytes.RemoveAt(bytes.Count - 1);
return DecodeText(bytes.ToArray());
}
private async Task<bool> EnsureAsync(int count, CancellationToken ct)
{
while (_end - _start < count)
if (!await FillAsync(ct))
return false;
return true;
}
/// <summary>Mueve lo pendiente al principio y lee mas. False si el flujo se acabo.</summary>
private async Task<bool> FillAsync(CancellationToken ct)
{
if (_eof)
return false;
if (_start > 0)
{
Buffer.BlockCopy(_buffer, _start, _buffer, 0, _end - _start);
_end -= _start;
_start = 0;
}
if (_end == _buffer.Length)
Array.Resize(ref _buffer, _buffer.Length * 2);
int n = await _stream.ReadAsync(_buffer.AsMemory(_end), ct);
if (n == 0)
{
_eof = true;
return false;
}
_end += n;
return true;
}
/// <summary>Datos de una parte, hasta el delimitador.</summary>
private sealed class SectionStream(MultipartReader owner) : Stream
{
private bool _done;
public override async ValueTask<int> ReadAsync(Memory<byte> destination, CancellationToken ct = default)
{
var r = owner;
while (!_done)
{
var available = r._buffer.AsSpan(r._start, r._end - r._start);
int found = available.IndexOf(r._delimiter);
if (found == 0)
{
r._start += r._delimiter.Length;
_done = true;
return 0;
}
// Sin delimitador a la vista se puede entregar todo menos lo que podria ser su comienzo.
int safe = found > 0 ? found : available.Length - (r._delimiter.Length - 1);
if (safe > 0)
{
int n = Math.Min(safe, destination.Length);
available[..n].CopyTo(destination.Span);
r._start += n;
return n;
}
if (!await r.FillAsync(ct))
throw new InvalidDataException("Truncated multipart body");
}
return 0;
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
ReadAsync(buffer.AsMemory(offset, count), ct).AsTask();
public override int Read(byte[] buffer, int offset, int count) =>
ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult();
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}