Newer
Older
Sunfish / src / Sunfish.Core / Web / MultipartReader.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 9 KB 3.0
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();
    }
}