view OrthancFramework/Sources/HttpServer/HttpOutput.cpp @ 4644:fa6482040c5c

todo
author Sebastien Jodogne <s.jodogne@gmail.com>
date Wed, 28 Apr 2021 13:40:35 +0200
parents c1f36fd13730
children d9942d48fea7
line wrap: on
line source

/**
 * Orthanc - A Lightweight, RESTful DICOM Store
 * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 * Department, University Hospital of Liege, Belgium
 * Copyright (C) 2017-2021 Osimis S.A., Belgium
 *
 * This program is free software: you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public License
 * as published by the Free Software Foundation, either version 3 of
 * the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this program. If not, see
 * <http://www.gnu.org/licenses/>.
 **/


#include "../PrecompiledHeaders.h"
#include "HttpOutput.h"

#include "../ChunkedBuffer.h"
#include "../Compression/GzipCompressor.h"
#include "../Compression/ZlibCompressor.h"
#include "../Logging.h"
#include "../OrthancException.h"
#include "../Toolbox.h"

#include <iostream>
#include <vector>
#include <stdio.h>
#include <boost/lexical_cast.hpp>


#if ORTHANC_ENABLE_CIVETWEB == 1
#  if !defined(CIVETWEB_HAS_DISABLE_KEEP_ALIVE)
#    error Macro CIVETWEB_HAS_DISABLE_KEEP_ALIVE must be defined
#  endif
#endif


namespace Orthanc
{
  HttpOutput::StateMachine::StateMachine(IHttpOutputStream& stream,
                                         bool isKeepAlive) : 
    stream_(stream),
    state_(State_WritingHeader),
    status_(HttpStatus_200_Ok),
    hasContentLength_(false),
    contentLength_(0),
    contentPosition_(0),
    keepAlive_(isKeepAlive)
  {
  }

  HttpOutput::StateMachine::~StateMachine()
  {
    if (state_ != State_Done)
    {
      //asm volatile ("int3;");
      //LOG(ERROR) << "This HTTP answer does not contain any body";
    }

    if (hasContentLength_ && contentPosition_ != contentLength_)
    {
      LOG(ERROR) << "This HTTP answer has not sent the proper number of bytes in its body";
    }
  }


  void HttpOutput::StateMachine::SetHttpStatus(HttpStatus status)
  {
    if (state_ != State_WritingHeader)
    {
      throw OrthancException(ErrorCode_BadSequenceOfCalls);
    }

    status_ = status;
  }


  void HttpOutput::StateMachine::SetContentLength(uint64_t length)
  {
    if (state_ != State_WritingHeader)
    {
      throw OrthancException(ErrorCode_BadSequenceOfCalls);
    }

    hasContentLength_ = true;
    contentLength_ = length;
  }

  void HttpOutput::StateMachine::SetContentType(const char* contentType)
  {
    AddHeader("Content-Type", contentType);
  }

  void HttpOutput::StateMachine::SetContentFilename(const char* filename)
  {
    // TODO Escape double quotes
    AddHeader("Content-Disposition", "filename=\"" + std::string(filename) + "\"");
  }

  void HttpOutput::StateMachine::SetCookie(const std::string& cookie,
                                           const std::string& value)
  {
    if (state_ != State_WritingHeader)
    {
      throw OrthancException(ErrorCode_BadSequenceOfCalls);
    }

    // TODO Escape "=" characters
    AddHeader("Set-Cookie", cookie + "=" + value);
  }


  void HttpOutput::StateMachine::AddHeader(const std::string& header,
                                           const std::string& value)
  {
    if (state_ != State_WritingHeader)
    {
      throw OrthancException(ErrorCode_BadSequenceOfCalls);
    }

    headers_.push_back(header + ": " + value + "\r\n");
  }

  void HttpOutput::StateMachine::ClearHeaders()
  {
    if (state_ != State_WritingHeader)
    {
      throw OrthancException(ErrorCode_BadSequenceOfCalls);
    }

    headers_.clear();
  }

  void HttpOutput::StateMachine::SendBody(const void* buffer, size_t length)
  {
    if (state_ == State_Done)
    {
      if (length == 0)
      {
        return;
      }
      else
      {
        throw OrthancException(ErrorCode_BadSequenceOfCalls,
                               "Because of keep-alive connections, the entire body must "
                               "be sent at once or Content-Length must be given");
      }
    }

    if (state_ == State_WritingMultipart)
    {
      throw OrthancException(ErrorCode_InternalError);
    }

    if (state_ == State_WritingHeader)
    {
      // Send the HTTP header before writing the body

      stream_.OnHttpStatusReceived(status_);

      std::string s = "HTTP/1.1 " + 
        boost::lexical_cast<std::string>(status_) +
        " " + std::string(EnumerationToString(status_)) +
        "\r\n";

      if (keepAlive_)
      {
        s += "Connection: keep-alive\r\n";

        /**
         * [LIFY-2311] The "Keep-Alive" HTTP header was missing in
         * Orthanc <= 1.8.0, which notably caused failures if
         * uploading DICOM instances by applying Java's
         * "org.apache.http.client.methods.HttpPost()" on "/instances"
         * URI, if "PoolingHttpClientConnectionManager" was in used. A
         * workaround was to manually set a timeout for the keep-alive
         * client to, say, 200 milliseconds, by using
         * "HttpClients.custom().setKeepAliveStrategy((httpResponse,httpContext)->200)".
         * Note that the "timeout" value can only be integer in the
         * HTTP header, so we can't use the milliseconds granularity.
         **/
        s += ("Keep-Alive: timeout=" +
              boost::lexical_cast<std::string>(CIVETWEB_KEEP_ALIVE_TIMEOUT_SECONDS) + "\r\n");
      }
      else
      {
        s += "Connection: close\r\n";
      }

      for (std::list<std::string>::const_iterator
             it = headers_.begin(); it != headers_.end(); ++it)
      {
        s += *it;
      }

      if (status_ != HttpStatus_200_Ok)
      {
        hasContentLength_ = false;
      }

      uint64_t contentLength = (hasContentLength_ ? contentLength_ : length);
      s += "Content-Length: " + boost::lexical_cast<std::string>(contentLength) + "\r\n\r\n";

      stream_.Send(true, s.c_str(), s.size());
      state_ = State_WritingBody;
    }

    if (hasContentLength_ &&
        contentPosition_ + length > contentLength_)
    {
      throw OrthancException(ErrorCode_BadSequenceOfCalls,
                             "The body size exceeds what was declared with SetContentSize()");
    }

    if (length > 0)
    {
      stream_.Send(false, buffer, length);
      contentPosition_ += length;
    }

    if (!hasContentLength_ ||
        contentPosition_ == contentLength_)
    {
      state_ = State_Done;
    }
  }


  void HttpOutput::StateMachine::CloseBody()
  {
    switch (state_)
    {
      case State_WritingHeader:
        SetContentLength(0);
        SendBody(NULL, 0);
        break;

      case State_WritingBody:
        if (!hasContentLength_ ||
            contentPosition_ == contentLength_)
        {
          state_ = State_Done;
        }
        else
        {
          throw OrthancException(ErrorCode_BadSequenceOfCalls,
                                 "The body size has not reached what was declared with SetContentSize()");
        }

        break;

      case State_WritingMultipart:
        throw OrthancException(ErrorCode_BadSequenceOfCalls,
                               "Cannot invoke CloseBody() with multipart outputs");

      case State_Done:
        return;  // Ignore

      default:
        throw OrthancException(ErrorCode_InternalError);
    }      
  }


  HttpCompression HttpOutput::GetPreferredCompression(size_t bodySize) const
  {
#if 0
    // TODO Do not compress small files?
    if (bodySize < 512)
    {
      return HttpCompression_None;
    }
#endif

    // Prefer "gzip" over "deflate" if the choice is offered

    if (isGzipAllowed_)
    {
      return HttpCompression_Gzip;
    }
    else if (isDeflateAllowed_)
    {
      return HttpCompression_Deflate;
    }
    else
    {
      return HttpCompression_None;
    }
  }


  HttpOutput::HttpOutput(IHttpOutputStream &stream,
                         bool isKeepAlive) :
    stateMachine_(stream, isKeepAlive),
    isDeflateAllowed_(false),
    isGzipAllowed_(false)
  {
  }

  void HttpOutput::SetDeflateAllowed(bool allowed)
  {
    isDeflateAllowed_ = allowed;
  }

  bool HttpOutput::IsDeflateAllowed() const
  {
    return isDeflateAllowed_;
  }

  void HttpOutput::SetGzipAllowed(bool allowed)
  {
    isGzipAllowed_ = allowed;
  }

  bool HttpOutput::IsGzipAllowed() const
  {
    return isGzipAllowed_;
  }


  void HttpOutput::SendMethodNotAllowed(const std::string& allowed)
  {
    stateMachine_.ClearHeaders();
    stateMachine_.SetHttpStatus(HttpStatus_405_MethodNotAllowed);
    stateMachine_.AddHeader("Allow", allowed);
    stateMachine_.SendBody(NULL, 0);
  }


  void HttpOutput::SendStatus(HttpStatus status,
			      const char* message,
			      size_t messageSize)
  {
    if (status == HttpStatus_301_MovedPermanently ||
        //status == HttpStatus_401_Unauthorized ||
        status == HttpStatus_405_MethodNotAllowed)
    {
      throw OrthancException(ErrorCode_ParameterOutOfRange,
                             "Please use the dedicated methods to this HTTP status code in HttpOutput");
    }
    
    stateMachine_.SetHttpStatus(status);
    stateMachine_.SendBody(message, messageSize);
  }

  void HttpOutput::SendStatus(HttpStatus status)
  {
    SendStatus(status, NULL, 0);
  }

  void HttpOutput::SendStatus(HttpStatus status, const std::string &message)
  {
    SendStatus(status, message.c_str(), message.size());
  }

  void HttpOutput::SetContentType(MimeType contentType)
  {
    stateMachine_.SetContentType(EnumerationToString(contentType));
  }

  void HttpOutput::SetContentType(const std::string &contentType)
  {
    stateMachine_.SetContentType(contentType.c_str());
  }

  void HttpOutput::SetContentFilename(const char *filename)
  {
    stateMachine_.SetContentFilename(filename);
  }

  void HttpOutput::SetCookie(const std::string &cookie, const std::string &value)
  {
    stateMachine_.SetCookie(cookie, value);
  }

  void HttpOutput::AddHeader(const std::string &key, const std::string &value)
  {
    stateMachine_.AddHeader(key, value);
  }


  void HttpOutput::Redirect(const std::string& path)
  {
    stateMachine_.ClearHeaders();
    stateMachine_.SetHttpStatus(HttpStatus_301_MovedPermanently);
    stateMachine_.AddHeader("Location", path);
    stateMachine_.SendBody(NULL, 0);
  }


  void HttpOutput::SendUnauthorized(const std::string& realm)
  {
    stateMachine_.ClearHeaders();
    stateMachine_.SetHttpStatus(HttpStatus_401_Unauthorized);
    stateMachine_.AddHeader("WWW-Authenticate", "Basic realm=\"" + realm + "\"");
    stateMachine_.SendBody(NULL, 0);
  }

  void HttpOutput::StartMultipart(const std::string &subType, const std::string &contentType)
  {
    stateMachine_.StartMultipart(subType, contentType);
  }

  void HttpOutput::SendMultipartItem(const void *item,
                                     size_t size,
                                     const std::map<std::string, std::string> &headers)
  {
    stateMachine_.SendMultipartItem(item, size, headers);
  }

  void HttpOutput::CloseMultipart()
  {
    stateMachine_.CloseMultipart();
  }

  bool HttpOutput::IsWritingMultipart() const
  {
    return stateMachine_.GetState() == StateMachine::State_WritingMultipart;
  }

  
  void HttpOutput::Answer(const void* buffer,
                          size_t length)
  {
    if (length == 0)
    {
      AnswerEmpty();
      return;
    }

    HttpCompression compression = GetPreferredCompression(length);

    if (compression == HttpCompression_None)
    {
      stateMachine_.SetContentLength(length);
      stateMachine_.SendBody(buffer, length);
      return;
    }

    std::string compressed, encoding;

    switch (compression)
    {
      case HttpCompression_Deflate:
      {
        encoding = "deflate";
        ZlibCompressor compressor;
        // Do not prefix the buffer with its uncompressed size, to be compatible with "deflate"
        compressor.SetPrefixWithUncompressedSize(false);  
        compressor.Compress(compressed, buffer, length);
        break;
      }

      case HttpCompression_Gzip:
      {
        encoding = "gzip";
        GzipCompressor compressor;
        compressor.Compress(compressed, buffer, length);
        break;
      }

      default:
        throw OrthancException(ErrorCode_InternalError);
    }

    LOG(TRACE) << "Compressing a HTTP answer using " << encoding;

    // The body is empty, do not use HTTP compression
    if (compressed.size() == 0)
    {
      AnswerEmpty();
    }
    else
    {
      stateMachine_.AddHeader("Content-Encoding", encoding);
      stateMachine_.SetContentLength(compressed.size());
      stateMachine_.SendBody(compressed.c_str(), compressed.size());
    }

    stateMachine_.CloseBody();
  }


  void HttpOutput::Answer(const std::string& str)
  {
    Answer(str.size() == 0 ? NULL : str.c_str(), str.size());
  }


  void HttpOutput::AnswerEmpty()
  {
    stateMachine_.CloseBody();
  }


  void HttpOutput::StateMachine::CheckHeadersCompatibilityWithMultipart() const
  {
    for (std::list<std::string>::const_iterator
           it = headers_.begin(); it != headers_.end(); ++it)
    {
      if (!Toolbox::StartsWith(*it, "Set-Cookie: "))
      {
        throw OrthancException(ErrorCode_BadSequenceOfCalls,
                               "The only headers that can be set in multipart answers "
                               "are Set-Cookie (here: " + *it + " is set)");
      }
    }
  }


  static void PrepareMultipartMainHeader(std::string& boundary,
                                         std::string& contentTypeHeader,
                                         const std::string& subType,
                                         const std::string& contentType)
  {
    if (subType != "mixed" &&
        subType != "related")
    {
      throw OrthancException(ErrorCode_ParameterOutOfRange);
    }

    /**
     * Fix for issue 54 ("Decide what to do wrt. quoting of multipart
     * answers"). The "type" parameter in the "Content-Type" HTTP
     * header must be quoted if it contains a forward slash "/". This
     * is necessary for DICOMweb compatibility with OsiriX, but breaks
     * compatibility with old releases of the client in the Orthanc
     * DICOMweb plugin <= 0.3 (releases >= 0.4 work fine).
     *
     * Full history is available at the following locations:
     * - In changeset 2248:69b0f4e8a49b:
     *   # hg history -v -r 2248
     * - https://bugs.orthanc-server.com/show_bug.cgi?id=54
     * - https://groups.google.com/d/msg/orthanc-users/65zhIM5xbKI/TU5Q1_LhAwAJ
     **/
    std::string tmp;
    if (contentType.find('/') == std::string::npos)
    {
      // No forward slash in the content type
      tmp = contentType;
    }
    else
    {
      // Quote the content type because of the forward slash
      tmp = "\"" + contentType + "\"";
    }

    boundary = Toolbox::GenerateUuid() + "-" + Toolbox::GenerateUuid();

    /**
     * Fix for issue #165: "Encapsulation boundaries must not appear
     * within the encapsulations, and must be no longer than 70
     * characters, not counting the two leading hyphens."
     * https://tools.ietf.org/html/rfc1521
     * https://bugs.orthanc-server.com/show_bug.cgi?id=165
     **/
    if (boundary.size() != 36 + 1 + 36)  // one UUID contains 36 characters
    {
      throw OrthancException(ErrorCode_InternalError);
    }
    
    boundary = boundary.substr(0, 70);
    
    contentTypeHeader = ("multipart/" + subType + "; type=" + tmp + "; boundary=" + boundary);
  }


  void HttpOutput::StateMachine::StartMultipart(const std::string& subType,
                                                const std::string& contentType)
  {
    if (state_ != State_WritingHeader)
    {
      throw OrthancException(ErrorCode_BadSequenceOfCalls);
    }

    if (status_ != HttpStatus_200_Ok)
    {
      SendBody(NULL, 0);
      return;
    }

    stream_.OnHttpStatusReceived(status_);

    std::string header = "HTTP/1.1 200 OK\r\n";

    if (keepAlive_)
    {
#if ORTHANC_ENABLE_MONGOOSE == 1
      throw OrthancException(ErrorCode_NotImplemented,
                             "Multipart answers are not implemented together "
                             "with keep-alive connections if using Mongoose");
      
#elif ORTHANC_ENABLE_CIVETWEB == 1
#  if CIVETWEB_HAS_DISABLE_KEEP_ALIVE == 1
      // Turn off Keep-Alive for multipart answers
      // https://github.com/civetweb/civetweb/issues/727
      stream_.DisableKeepAlive();
      header += "Connection: close\r\n";
#  else
      // The function "mg_disable_keep_alive()" is not available,
      // let's continue with Keep-Alive. Performance of WADO-RS will
      // decrease.
      header += "Connection: keep-alive\r\n";
#  endif   

#else
#  error Please support your embedded Web server here
#endif
    }
    else
    {
      header += "Connection: close\r\n";
    }

    // Possibly add the cookies
    CheckHeadersCompatibilityWithMultipart();

    for (std::list<std::string>::const_iterator
           it = headers_.begin(); it != headers_.end(); ++it)
    {
      header += *it;
    }

    std::string contentTypeHeader;
    PrepareMultipartMainHeader(multipartBoundary_, contentTypeHeader, subType, contentType);
    multipartContentType_ = contentType;
    header += ("Content-Type: " + contentTypeHeader + "\r\n\r\n");

    stream_.Send(true, header.c_str(), header.size());
    state_ = State_WritingMultipart;
  }


  static void PrepareMultipartItemHeader(std::string& target,
                                         size_t length,
                                         const std::map<std::string, std::string>& headers,
                                         const std::string& boundary,
                                         const std::string& contentType)
  {
    target = "--" + boundary + "\r\n";

    bool hasContentType = false;
    bool hasContentLength = false;
    bool hasMimeVersion = false;

    for (std::map<std::string, std::string>::const_iterator
           it = headers.begin(); it != headers.end(); ++it)
    {
      target += it->first + ": " + it->second + "\r\n";

      std::string tmp;
      Toolbox::ToLowerCase(tmp, it->first);

      if (tmp == "content-type")
      {
        hasContentType = true;
      }

      if (tmp == "content-length")
      {
        hasContentLength = true;
      }

      if (tmp == "mime-version")
      {
        hasMimeVersion = true;
      }
    }

    if (!hasContentType)
    {
      target += "Content-Type: " + contentType + "\r\n";
    }

    if (!hasContentLength)
    {
      target += "Content-Length: " + boost::lexical_cast<std::string>(length) + "\r\n";
    }

    if (!hasMimeVersion)
    {
      target += "MIME-Version: 1.0\r\n\r\n";
    }
  }


  void HttpOutput::StateMachine::SendMultipartItem(const void* item,
                                                   size_t length,
                                                   const std::map<std::string, std::string>& headers)
  {
    if (state_ != State_WritingMultipart)
    {
      throw OrthancException(ErrorCode_BadSequenceOfCalls);
    }

    std::string header;
    PrepareMultipartItemHeader(header, length, headers, multipartBoundary_, multipartContentType_);
    stream_.Send(false, header.c_str(), header.size());

    if (length > 0)
    {
      stream_.Send(false, item, length);
    }

    stream_.Send(false, "\r\n", 2);    
  }


  void HttpOutput::StateMachine::CloseMultipart()
  {
    if (state_ != State_WritingMultipart)
    {
      throw OrthancException(ErrorCode_BadSequenceOfCalls);
    }

    // The two lines below might throw an exception, if the client has
    // closed the connection. Such an error is ignored.
    try
    {
      std::string header = "--" + multipartBoundary_ + "--\r\n";
      stream_.Send(false, header.c_str(), header.size());
    }
    catch (OrthancException&)
    {
    }

    state_ = State_Done;
  }


  static void AnswerStreamAsBuffer(HttpOutput& output,
                                   IHttpStreamAnswer& stream)
  {
    ChunkedBuffer buffer;

    while (stream.ReadNextChunk())
    {
      if (stream.GetChunkSize() > 0)
      {
        buffer.AddChunk(stream.GetChunkContent(), stream.GetChunkSize());
      }
    }

    std::string s;
    buffer.Flatten(s);

    output.SetContentType(stream.GetContentType());
    
    std::string filename;
    if (stream.HasContentFilename(filename))
    {
      output.SetContentFilename(filename.c_str());
    }

    output.Answer(s);
  }


  void HttpOutput::Answer(IHttpStreamAnswer& stream)
  {
    HttpCompression compression = stream.SetupHttpCompression(isGzipAllowed_, isDeflateAllowed_);

    switch (compression)
    {
      case HttpCompression_None:
      {
        if (isGzipAllowed_ || isDeflateAllowed_)
        {
          // New in Orthanc 1.5.7: Compress streams without built-in
          // compression, if requested by the "Accept-Encoding" HTTP
          // header
          AnswerStreamAsBuffer(*this, stream);
          return;
        }
        
        break;
      }

      case HttpCompression_Gzip:
        stateMachine_.AddHeader("Content-Encoding", "gzip");
        break;

      case HttpCompression_Deflate:
        stateMachine_.AddHeader("Content-Encoding", "deflate");
        break;

      default:
        throw OrthancException(ErrorCode_ParameterOutOfRange);
    }

    stateMachine_.SetContentLength(stream.GetContentLength());

    std::string contentType = stream.GetContentType();
    if (contentType.empty())
    {
      contentType = MIME_BINARY;
    }

    stateMachine_.SetContentType(contentType.c_str());

    std::string filename;
    if (stream.HasContentFilename(filename))
    {
      SetContentFilename(filename.c_str());
    }

    while (stream.ReadNextChunk())
    {
      stateMachine_.SendBody(stream.GetChunkContent(),
                             stream.GetChunkSize());
    }

    stateMachine_.CloseBody();
  }


  void HttpOutput::AnswerMultipartWithoutChunkedTransfer(
    const std::string& subType,
    const std::string& contentType,
    const std::vector<const void*>& parts,
    const std::vector<size_t>& sizes,
    const std::vector<const std::map<std::string, std::string>*>& headers)
  {
    if (parts.size() != sizes.size())
    {
      throw OrthancException(ErrorCode_ParameterOutOfRange);
    }

    stateMachine_.CheckHeadersCompatibilityWithMultipart();

    std::string boundary, contentTypeHeader;
    PrepareMultipartMainHeader(boundary, contentTypeHeader, subType, contentType);
    SetContentType(contentTypeHeader);

    std::map<std::string, std::string> empty;

    ChunkedBuffer chunked;
    for (size_t i = 0; i < parts.size(); i++)
    {
      std::string partHeader;
      PrepareMultipartItemHeader(partHeader, sizes[i], headers[i] == NULL ? empty : *headers[i], 
                                 boundary, contentType);

      chunked.AddChunk(partHeader);
      chunked.AddChunk(parts[i], sizes[i]);
      chunked.AddChunk("\r\n");    
    }

    chunked.AddChunk("--" + boundary + "--\r\n");

    std::string body;
    chunked.Flatten(body);
    Answer(body);
  }
}