unit Sparkle.Multipart.Stream;

{$I Sparkle.inc}

interface

uses
  System.SysUtils, System.Classes, System.StrUtils,
  System.Generics.Collections;

type
  TMultipartState = (Start, ReadingPart, PartFinished, Epilogue);

  TMultipartStream = class(TStream)
  private
  public
    type
      EEndOfInputReached = class(Exception)
      public
        constructor Create;
      end;
      EInvalidMultipart = class(Exception)
      public
        constructor Create; overload;
        constructor Create(const Msg: string); overload;
      end;
  strict private const
    DefaultBuffSize = 16384;
  strict private
    FStream: TStream;
    FStreamSize: Int64;
    FBoundary: TArray<Byte>;
    FBoundaryLength: Integer;
    FBuffer: TArray<Byte>;
    FBuffPos: Integer;
    FBuffSize: Integer;
    FState: TMultipartState;
    function PeekByte: Byte;
    procedure CheckStart;
    procedure SkipWhitespaceAndCRLF;
    procedure FillBuffer(TargetPos: Integer);
    function BytesInBuffer: integer;
    function InternalRead(Buffer: Pointer; Count: Longint): Longint;
    function CompareBoundary(Buffer: Pointer; Len: Integer = 0): Boolean;
  public
    constructor Create(AStream: TStream; AContentType: string); overload;
    constructor Create(AStream: TStream; Boundary: TArray<Byte>); overload;
    function Read(var Buffer; Count: Longint): Longint; override;
    function Write(const Buffer; Count: Longint): Longint; override;
    function Seek(const Offset: Int64; Origin: TSeekOrigin): Int64; override;
    function NextPart: Boolean;
    property State: TMultipartState read FState;
  end;

implementation

uses
  Math,
  Sparkle.Utils;

{ TMultipartStream }

function TMultipartStream.BytesInBuffer: Integer;
begin
  Result := FBuffSize - FBuffPos;
  if Result <= 0 then
  begin
    FBuffPos := 0;
    FillBuffer(FBuffPos);
    Result := FBuffSize - FBuffPos;
  end;
end;

function TMultipartStream.CompareBoundary(Buffer: Pointer;
  Len: Integer): Boolean;
begin
  if Len = 0 then
    Len := FBoundaryLength;
  Result := CompareMem(Buffer, @FBoundary[0], Len);
end;

constructor TMultipartStream.Create(AStream: TStream; AContentType: string);
var
  Boundary: TArray<Byte>;
  Info: TContentTypeInfo;
begin
  Info := TSparkleUtils.ParseContentType(AContentType);
  if StartsText('multipart/', Info.Value) then
  begin
    // using utf8 as quick and dirty conversion. Not exactly the correct one
    Boundary := TEncoding.UTF8.GetBytes(Info.ParamValue('boundary'));
    if Length(Boundary) = 0 then
      raise EInvalidMultipart.Create('Boundary not specified in multipart header');
  end
  else
    raise EInvalidMultipart.CreateFmt('Invalid media type. Expected multipart/*, actual %s', [Info.Value]);

  Create(AStream, Boundary);
end;

constructor TMultipartStream.Create(AStream: TStream; Boundary: TArray<Byte>);
begin
  inherited Create;
  FStream := AStream;

  FBuffSize := DefaultBuffSize;
  FBuffPos := FBuffSize; // force first buffer filling
  SetLength(FBuffer, FBuffSize);

  // build boundary with leading CRLF
  FBoundaryLength := Length(Boundary) + 4;
  SetLength(FBoundary, FBoundaryLength);
  FBoundary[0] := 13;
  FBoundary[1] := 10;
  FBoundary[2] := 45;
  FBoundary[3] := 45;
  System.Move(Boundary[0], FBoundary[4], Length(Boundary));

  // buffer size must be bigger than boundary size
  Assert(DefaultBuffSize > FBoundaryLength);
end;

procedure TMultipartStream.FillBuffer(TargetPos: Integer);
var
  DataRead: integer;
begin
  Assert(TargetPos < FBuffSize);
  DataRead := FStream.Read(FBuffer[TargetPos], FBuffSize - TargetPos);
  if (DataRead <> FBuffSize - TargetPos) then
  begin
    FBuffSize := TargetPos + DataRead;
    SetLength(FBuffer, FBuffSize);
  end;
end;

function TMultipartStream.InternalRead(Buffer: Pointer;
  Count: Longint): Longint;
var
  B: Byte;
  StillInBuff: Integer;
  DestBuff: PByte;
  LocalBuffPos: integer;
  OldBuffSize: Integer;

  procedure FlushPendingBytes(var TotalCount: Longint);
  begin
    if LocalBuffPos > FBuffPos then
    begin
      if Assigned(DestBuff) then
      begin
        System.Move(FBuffer[FBuffPos], DestBuff^, LocalBuffPos - FBuffPos);
        Inc(DestBuff, LocalBuffPos - FBuffPos);
      end;
      Inc(TotalCount, LocalBuffPos - FBuffPos);
      FBuffPos := LocalBuffPos;
    end;
  end;

begin
  Result := 0;
  DestBuff := PByte(Buffer);
  while Result < Count do
  begin
    StillInBuff := BytesInBuffer;
    if StillInBuff = 0 then
    begin
      if FState = TMultipartState.Epilogue then
        Exit
      else
        raise EEndOfInputReached.Create;
    end;

    LocalBuffPos := FBuffPos;
    while StillInBuff > 0 do
    begin
      // Check if total requested bytes were read
      if Result + (LocalBuffPos - FBuffPos) >= Count then
        break;

      B := FBuffer[LocalBuffPos];
      if (B = 13) and (FState <> TMultipartState.Epilogue) then
      begin
        // Compare the available bytes with boundary length
        if CompareBoundary(@FBuffer[LocalBuffPos], Min(StillInBuff, FBoundaryLength)) then
        begin
          // include trailing -- of last part, thus the "+2"
          if (StillInBuff < FBoundaryLength + 2) then
          begin
            // Edge case for optimization:
            // If there are not enough bytes in buffer to hold the whole boundary,
            // then increase the buffer to hold the whole boundar for comparison
            OldBuffSize := FBuffSize;
            Inc(FBuffSize, FBoundaryLength + 2 - StillInBuff);
            SetLength(FBuffer, FBuffSize);
            FillBuffer(OldBuffSize);
            StillInBuff := FBuffSize - LocalBuffPos;
            if StillInBuff < FBoundaryLength + 2 then
              raise EEndOfInputReached.Create;
          end;

          // At this point, we should have enough space for boundary length plus --
          // Check if Boundary was reached
          if CompareBoundary(@FBuffer[LocalBuffPos]) and
            (FBuffer[LocalBuffPos + FBoundaryLength] in [9, 13, 32, 45]) then
          begin
            FlushPendingBytes(Result);
            Inc(FBuffPos, FBoundaryLength);
            FState := TMultipartState.PartFinished;
            if (FBuffer[FBuffPos] = 45) and (FBuffer[FBuffPos + 1] = 45) then
            begin
              Inc(FBuffPos, 2);
              FState := TMultipartState.Epilogue;
            end;
            SkipWhitespaceAndCRLF;
            Exit;
          end;
        end;
      end;
      Inc(LocalBuffPos);
      Dec(StillInBuff);
    end;

    FlushPendingBytes(Result);
  end;
end;

function TMultipartStream.NextPart: Boolean;
begin
  CheckStart;
  case FState of
    TMultipartState.ReadingPart:
      InternalRead(nil, MaxInt);
    TMultipartState.PartFinished:
      FState := TMultipartState.ReadingPart;
  end;
  Result := FState <> TMultipartState.Epilogue;
  if Result then
    FStreamSize := 0;
end;

function TMultipartStream.PeekByte: Byte;
begin
  if BytesInBuffer = 0 then
    raise EEndOfInputReached.Create;
  Result := FBuffer[FBuffPos];
end;

function TMultipartStream.Read(var Buffer; Count: Longint): Longint;
begin
  CheckStart;
  case FState of
    TMultipartState.ReadingPart, TMultipartState.Epilogue:
      Result := InternalRead(@Buffer, Count)
  else
    Result := 0;
  end;
  FStreamSize := FStreamSize + Result;
end;

procedure TMultipartStream.CheckStart;
begin
  if FState <> TMultipartState.Start then Exit;

  if BytesInBuffer < FBoundaryLength then
    raise EEndOfInputReached.Create;

  // Compare boundary with no CRLF
  if CompareMem(@FBuffer[0], @FBoundary[2], FBoundaryLength - 2) then
  begin
    Inc(FBuffPos, FBoundaryLength - 2);
    SkipWhitespaceAndCRLF;
    FState := TMultipartState.PartFinished;
  end
  else
    FState := TMultipartState.ReadingPart;
end;

function TMultipartStream.Seek(const Offset: Int64; Origin: TSeekOrigin): Int64;
begin
  case Origin of
    soBeginning:
      if FStreamSize = Offset then Exit(FStreamSize);
    soCurrent, soEnd:
      if Offset = 0 then
        Exit(FStreamSize);
  end;
  raise EStreamError.Create('Http request stream cannot change position.');
end;

procedure TMultipartStream.SkipWhitespaceAndCRLF;
begin
  while PeekByte in [9, 32] do
  begin
    Inc(FBuffPos);
    if BytesInBuffer = 0 then
      raise EEndOfInputReached.Create;
  end;

  if PeekByte <> 13 then
    raise EInvalidMultipart.Create;
  Inc(FBuffPos);
  if PeekByte <> 10 then
    raise EInvalidMultipart.Create;
  Inc(FBuffPos);
end;

function TMultipartStream.Write(const Buffer; Count: Longint): Longint;
begin
  Result := 0;
end;

{ TMultipartStream.EEndOfInputReached }

constructor TMultipartStream.EEndOfInputReached.Create;
begin
  inherited Create('End of byte stream reader reached.');
end;

{ TMultipartStream.EInvalidMultipart }

constructor TMultipartStream.EInvalidMultipart.Create;
begin
  inherited Create('Invalid multipart content');
end;

constructor TMultipartStream.EInvalidMultipart.Create(const Msg: string);
begin
  inherited Create(Msg);
end;

end.
