changeset 5901:cc5a6f3b9bbe default

support HTTP "Range" request header on attachments
author Sebastien Jodogne <s.jodogne@gmail.com>
date Wed, 04 Dec 2024 18:16:44 +0100
parents 1e51e6299f7a
children 3d13bd97b281
files NEWS OrthancFramework/Sources/FileStorage/StorageAccessor.cpp OrthancFramework/Sources/FileStorage/StorageAccessor.h OrthancFramework/Sources/HttpServer/HttpOutput.cpp OrthancFramework/Sources/HttpServer/HttpOutput.h OrthancFramework/UnitTestsSources/FileStorageTests.cpp OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp OrthancServer/Sources/ServerContext.cpp OrthancServer/Sources/ServerContext.h
diffstat 9 files changed, 558 insertions(+), 107 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Mon Nov 25 17:03:47 2024 +0100
+++ b/NEWS	Wed Dec 04 18:16:44 2024 +0100
@@ -5,6 +5,8 @@
 -----------
 
 * API version upgraded to 26
+* Support HTTP "Range" request header on "{...}/attachments/{...}/data" and
+  "{...}/attachments/{...}/compressed-data"
 * Improved parsing of multiple numerical values in DICOM tags.
   https://discourse.orthanc-server.org/t/qido-includefield-with-sequences/4746/6
 * In DICOMWeb json, the "DS - Decimal String" values were represented by float numbers
--- a/OrthancFramework/Sources/FileStorage/StorageAccessor.cpp	Mon Nov 25 17:03:47 2024 +0100
+++ b/OrthancFramework/Sources/FileStorage/StorageAccessor.cpp	Wed Dec 04 18:16:44 2024 +0100
@@ -28,16 +28,18 @@
 
 #include "../Logging.h"
 #include "../StringMemoryBuffer.h"
-#include "../Compatibility.h"
 #include "../Compression/ZlibCompressor.h"
 #include "../MetricsRegistry.h"
 #include "../OrthancException.h"
+#include "../SerializationToolbox.h"
 #include "../Toolbox.h"
 
 #if ORTHANC_ENABLE_CIVETWEB == 1 || ORTHANC_ENABLE_MONGOOSE == 1
 #  include "../HttpServer/HttpStreamTranscoder.h"
 #endif
 
+#include <boost/algorithm/string.hpp>
+
 
 static const std::string METRICS_CREATE_DURATION = "orthanc_storage_create_duration_ms";
 static const std::string METRICS_READ_DURATION = "orthanc_storage_read_duration_ms";
@@ -50,6 +52,212 @@
 
 namespace Orthanc
 {
+  void StorageAccessor::Range::SanityCheck() const
+  {
+    if (hasStart_ && hasEnd_ && start_ > end_)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+  }
+
+  StorageAccessor::Range::Range():
+    hasStart_(false),
+    start_(0),
+    hasEnd_(false),
+    end_(0)
+  {
+  }
+
+  void StorageAccessor::Range::SetStartInclusive(uint64_t start)
+  {
+    hasStart_ = true;
+    start_ = start;
+  }
+
+  void StorageAccessor::Range::SetEndInclusive(uint64_t end)
+  {
+    hasEnd_ = true;
+    end_ = end;
+  }
+
+  uint64_t StorageAccessor::Range::GetStartInclusive() const
+  {
+    if (!hasStart_)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else if (hasEnd_ && start_ > end_)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      return start_;
+    }
+  }
+
+  uint64_t StorageAccessor::Range::GetEndInclusive() const
+  {
+    if (!hasEnd_)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else if (hasStart_ && start_ > end_)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      return end_;
+    }
+  }
+
+  std::string StorageAccessor::Range::FormatHttpContentRange(uint64_t fullSize) const
+  {
+    SanityCheck();
+
+    if (fullSize == 0 ||
+        (hasStart_ && start_ >= fullSize) ||
+        (hasEnd_ && end_ >= fullSize))
+    {
+      throw OrthancException(ErrorCode_BadRange);
+    }
+
+    std::string s = "bytes ";
+
+    if (hasStart_)
+    {
+      s += boost::lexical_cast<std::string>(start_);
+    }
+    else
+    {
+      s += "0";
+    }
+
+    s += "-";
+
+    if (hasEnd_)
+    {
+      s += boost::lexical_cast<std::string>(end_);
+    }
+    else
+    {
+      s += boost::lexical_cast<std::string>(fullSize - 1);
+    }
+
+    return s + "/" + boost::lexical_cast<std::string>(fullSize);
+  }
+
+  void StorageAccessor::Range::Extract(std::string &target,
+                                       const std::string &source) const
+  {
+    SanityCheck();
+
+    if (hasStart_ && start_ >= source.size())
+    {
+      throw OrthancException(ErrorCode_BadRange);
+    }
+
+    if (hasEnd_ && end_ >= source.size())
+    {
+      throw OrthancException(ErrorCode_BadRange);
+    }
+
+    if (hasStart_ && hasEnd_)
+    {
+      target = source.substr(start_, end_ - start_ + 1);
+    }
+    else if (hasStart_)
+    {
+      target = source.substr(start_, source.size() - start_);
+    }
+    else if (hasEnd_)
+    {
+      target = source.substr(0, end_ + 1);
+    }
+    else
+    {
+      target = source;
+    }
+  }
+
+  uint64_t StorageAccessor::Range::GetContentLength(uint64_t fullSize) const
+  {
+    SanityCheck();
+
+    if (fullSize == 0)
+    {
+      throw OrthancException(ErrorCode_BadRange);
+    }
+
+    if (hasStart_ && start_ >= fullSize)
+    {
+      throw OrthancException(ErrorCode_BadRange);
+    }
+
+    if (hasEnd_ && end_ >= fullSize)
+    {
+      throw OrthancException(ErrorCode_BadRange);
+    }
+
+    if (hasStart_ && hasEnd_)
+    {
+      return end_ - start_ + 1;
+    }
+    else if (hasStart_)
+    {
+      return fullSize - start_;
+    }
+    else if (hasEnd_)
+    {
+      return end_ + 1;
+    }
+    else
+    {
+      return fullSize;
+    }
+  }
+
+  StorageAccessor::Range StorageAccessor::Range::ParseHttpRange(const std::string& s)
+  {
+    static const std::string BYTES = "bytes=";
+
+    if (!boost::starts_with(s, BYTES))
+    {
+      throw OrthancException(ErrorCode_BadRange);  // Range not satisfiable
+    }
+
+    std::vector<std::string> tokens;
+    Orthanc::Toolbox::TokenizeString(tokens, s.substr(BYTES.length()), '-');
+
+    if (tokens.size() != 2)
+    {
+      throw OrthancException(ErrorCode_BadRange);
+    }
+
+    Range range;
+
+    uint64_t tmp;
+    if (!tokens[0].empty())
+    {
+      if (SerializationToolbox::ParseUnsignedInteger64(tmp, tokens[0]))
+      {
+        range.SetStartInclusive(tmp);
+      }
+    }
+
+    if (!tokens[1].empty())
+    {
+      if (SerializationToolbox::ParseUnsignedInteger64(tmp, tokens[1]))
+      {
+        range.SetEndInclusive(tmp);
+      }
+    }
+
+    range.SanityCheck();
+    return range;
+  }
+
   class StorageAccessor::MetricsTimer : public boost::noncopyable
   {
   private:
@@ -351,15 +559,6 @@
   }
 
 
-  void ReadStartRangeFromAreaInternal(std::string& target,
-                                      IStorageArea& area,
-                                      const std::string& fileUuid,
-                                      FileContentType contentType,
-                                      uint64_t end /* exclusive */)
-  {
-
-  }
-
   void StorageAccessor::ReadStartRange(std::string& target,
                                        const FileInfo& info,
                                        uint64_t end /* exclusive */)
@@ -430,6 +629,79 @@
   }
 
 
+  void StorageAccessor::ReadRange(std::string &target,
+                                  const FileInfo &info,
+                                  const Range &range,
+                                  bool uncompressIfNeeded)
+  {
+    if (uncompressIfNeeded &&
+        info.GetCompressionType() != CompressionType_None)
+    {
+      // An uncompression is needed in this case
+      if (cache_ != NULL)
+      {
+        StorageCache::Accessor cacheAccessor(*cache_);
+
+        std::string content;
+        if (cacheAccessor.Fetch(content, info.GetUuid(), info.GetContentType()))
+        {
+          range.Extract(target, content);
+          return;
+        }
+      }
+
+      std::string content;
+      Read(content, info);
+      range.Extract(target, content);
+    }
+    else
+    {
+      // Access to the raw attachment is sufficient in this case
+      if (info.GetCompressionType() == CompressionType_None &&
+          cache_ != NULL)
+      {
+        // Check out whether the raw attachment is already present in the cache, by chance
+        StorageCache::Accessor cacheAccessor(*cache_);
+
+        std::string content;
+        if (cacheAccessor.Fetch(content, info.GetUuid(), info.GetContentType()))
+        {
+          range.Extract(target, content);
+          return;
+        }
+      }
+
+      if (range.HasEnd() &&
+        range.GetEndInclusive() >= info.GetCompressedSize())
+      {
+        throw OrthancException(ErrorCode_BadRange);
+      }
+
+      std::unique_ptr<IMemoryBuffer> buffer;
+
+      if (range.HasStart() &&
+          range.HasEnd())
+      {
+        buffer.reset(area_.ReadRange(info.GetUuid(), info.GetContentType(), range.GetStartInclusive(), range.GetEndInclusive() + 1));
+      }
+      else if (range.HasStart())
+      {
+        buffer.reset(area_.ReadRange(info.GetUuid(), info.GetContentType(), range.GetStartInclusive(), info.GetCompressedSize()));
+      }
+      else if (range.HasEnd())
+      {
+        buffer.reset(area_.ReadRange(info.GetUuid(), info.GetContentType(), 0, range.GetEndInclusive() + 1));
+      }
+      else
+      {
+        buffer.reset(area_.Read(info.GetUuid(), info.GetContentType()));
+      }
+
+      buffer->MoveToString(target);
+    }
+  }
+
+
 #if ORTHANC_ENABLE_CIVETWEB == 1 || ORTHANC_ENABLE_MONGOOSE == 1
   void StorageAccessor::SetupSender(BufferHttpSender& sender,
                                     const FileInfo& info,
--- a/OrthancFramework/Sources/FileStorage/StorageAccessor.h	Mon Nov 25 17:03:47 2024 +0100
+++ b/OrthancFramework/Sources/FileStorage/StorageAccessor.h	Wed Dec 04 18:16:44 2024 +0100
@@ -65,6 +65,48 @@
    **/
   class ORTHANC_PUBLIC StorageAccessor : boost::noncopyable
   {
+  public:
+    class ORTHANC_PUBLIC Range
+    {
+    private:
+      bool      hasStart_;
+      uint64_t  start_;
+      bool      hasEnd_;
+      uint64_t  end_;
+
+      void SanityCheck() const;
+
+    public:
+      Range();
+
+      void SetStartInclusive(uint64_t start);
+
+      void SetEndInclusive(uint64_t end);
+
+      bool HasStart() const
+      {
+        return hasStart_;
+      }
+
+      bool HasEnd() const
+      {
+        return hasEnd_;
+      }
+
+      uint64_t GetStartInclusive() const;
+
+      uint64_t GetEndInclusive() const;
+
+      std::string FormatHttpContentRange(uint64_t fullSize) const;
+
+      void Extract(std::string& target,
+                   const std::string& source) const;
+
+      uint64_t GetContentLength(uint64_t fullSize) const;
+
+      static Range ParseHttpRange(const std::string& s);
+    };
+
   private:
     class MetricsTimer;
 
@@ -117,6 +159,11 @@
 
     void Remove(const FileInfo& info);
 
+    void ReadRange(std::string& target,
+                   const FileInfo& info,
+                   const Range& range,
+                   bool uncompressIfNeeded);
+
 #if ORTHANC_ENABLE_CIVETWEB == 1 || ORTHANC_ENABLE_MONGOOSE == 1
     void AnswerFile(HttpOutput& output,
                     const FileInfo& info,
--- a/OrthancFramework/Sources/HttpServer/HttpOutput.cpp	Mon Nov 25 17:03:47 2024 +0100
+++ b/OrthancFramework/Sources/HttpServer/HttpOutput.cpp	Wed Dec 04 18:16:44 2024 +0100
@@ -62,7 +62,8 @@
     contentPosition_(0),
     keepAlive_(isKeepAlive),
     keepAliveTimeout_(keepAliveTimeout),
-    hasXContentTypeOptions_(false)
+    hasXContentTypeOptions_(false),
+    hasContentType_(false)
   {
   }
 
@@ -105,6 +106,7 @@
 
   void HttpOutput::StateMachine::SetContentType(const char* contentType)
   {
+    hasContentType_ = true;
     AddHeader("Content-Type", contentType);
   }
 
@@ -380,7 +382,8 @@
     
     stateMachine_.SetHttpStatus(status);
 
-    if (messageSize > 0)
+    if (messageSize > 0 &&
+      !stateMachine_.HasContentType())
     {
       // Assume that the body always contains a textual description of the error
       stateMachine_.SetContentType("text/plain");
--- a/OrthancFramework/Sources/HttpServer/HttpOutput.h	Mon Nov 25 17:03:47 2024 +0100
+++ b/OrthancFramework/Sources/HttpServer/HttpOutput.h	Wed Dec 04 18:16:44 2024 +0100
@@ -66,6 +66,7 @@
       unsigned int keepAliveTimeout_;
       std::list<std::string> headers_;
       bool hasXContentTypeOptions_;
+      bool hasContentType_;
 
       std::string multipartBoundary_;
       std::string multipartContentType_;
@@ -125,6 +126,11 @@
                           size_t size);
 
       void CloseStream();
+
+      bool HasContentType() const
+      {
+        return hasContentType_;
+      }
     };
 
     StateMachine stateMachine_;
--- a/OrthancFramework/UnitTestsSources/FileStorageTests.cpp	Mon Nov 25 17:03:47 2024 +0100
+++ b/OrthancFramework/UnitTestsSources/FileStorageTests.cpp	Wed Dec 04 18:16:44 2024 +0100
@@ -236,3 +236,92 @@
   ASSERT_THROW(accessor.Read(r, uncompressedInfo.GetUuid(), FileContentType_Unknown), OrthancException);
   */
 }
+
+
+TEST(StorageAccessor, Range)
+{
+  {
+    StorageAccessor::Range range;
+    ASSERT_FALSE(range.HasStart());
+    ASSERT_FALSE(range.HasEnd());
+    ASSERT_THROW(range.GetStartInclusive(), OrthancException);
+    ASSERT_THROW(range.GetEndInclusive(), OrthancException);
+    ASSERT_EQ("bytes 0-99/100", range.FormatHttpContentRange(100));
+    ASSERT_EQ("bytes 0-0/1", range.FormatHttpContentRange(1));
+    ASSERT_THROW(range.FormatHttpContentRange(0), OrthancException);
+    ASSERT_EQ(100u, range.GetContentLength(100));
+    ASSERT_EQ(1u, range.GetContentLength(1));
+    ASSERT_THROW(range.GetContentLength(0), OrthancException);
+
+    range.SetStartInclusive(10);
+    ASSERT_TRUE(range.HasStart());
+    ASSERT_FALSE(range.HasEnd());
+    ASSERT_EQ(10u, range.GetStartInclusive());
+    ASSERT_THROW(range.GetEndInclusive(), OrthancException);
+    ASSERT_EQ("bytes 10-99/100", range.FormatHttpContentRange(100));
+    ASSERT_EQ("bytes 10-10/11", range.FormatHttpContentRange(11));
+    ASSERT_THROW(range.FormatHttpContentRange(10), OrthancException);
+    ASSERT_EQ(90u, range.GetContentLength(100));
+    ASSERT_EQ(1u, range.GetContentLength(11));
+    ASSERT_THROW(range.GetContentLength(10), OrthancException);
+
+    range.SetEndInclusive(30);
+    ASSERT_TRUE(range.HasStart());
+    ASSERT_TRUE(range.HasEnd());
+    ASSERT_EQ(10u, range.GetStartInclusive());
+    ASSERT_EQ(30u, range.GetEndInclusive());
+    ASSERT_EQ("bytes 10-30/100", range.FormatHttpContentRange(100));
+    ASSERT_EQ("bytes 10-30/31", range.FormatHttpContentRange(31));
+    ASSERT_THROW(range.FormatHttpContentRange(30), OrthancException);
+    ASSERT_EQ(21u, range.GetContentLength(100));
+    ASSERT_EQ(21u, range.GetContentLength(31));
+    ASSERT_THROW(range.GetContentLength(30), OrthancException);
+  }
+
+  {
+    StorageAccessor::Range range;
+    range.SetEndInclusive(20);
+    ASSERT_FALSE(range.HasStart());
+    ASSERT_TRUE(range.HasEnd());
+    ASSERT_THROW(range.GetStartInclusive(), OrthancException);
+    ASSERT_EQ(20u, range.GetEndInclusive());
+    ASSERT_EQ("bytes 0-20/100", range.FormatHttpContentRange(100));
+    ASSERT_EQ("bytes 0-20/21", range.FormatHttpContentRange(21));
+    ASSERT_THROW(range.FormatHttpContentRange(20), OrthancException);
+    ASSERT_EQ(21u, range.GetContentLength(100));
+    ASSERT_EQ(21u, range.GetContentLength(21));
+    ASSERT_THROW(range.GetContentLength(20), OrthancException);
+  }
+
+  {
+    StorageAccessor::Range range = StorageAccessor::Range::ParseHttpRange("bytes=1-30");
+    ASSERT_TRUE(range.HasStart());
+    ASSERT_TRUE(range.HasEnd());
+    ASSERT_EQ(1u, range.GetStartInclusive());
+    ASSERT_EQ(30u, range.GetEndInclusive());
+    ASSERT_EQ("bytes 1-30/100", range.FormatHttpContentRange(100));
+  }
+
+  ASSERT_THROW(StorageAccessor::Range::ParseHttpRange("bytes="), OrthancException);
+  ASSERT_THROW(StorageAccessor::Range::ParseHttpRange("bytes=-1-30"), OrthancException);
+  ASSERT_THROW(StorageAccessor::Range::ParseHttpRange("bytes=100-30"), OrthancException);
+
+  ASSERT_EQ("bytes 0-99/100", StorageAccessor::Range::ParseHttpRange("bytes=-").FormatHttpContentRange(100));
+  ASSERT_EQ("bytes 0-10/100", StorageAccessor::Range::ParseHttpRange("bytes=-10").FormatHttpContentRange(100));
+  ASSERT_EQ("bytes 10-99/100", StorageAccessor::Range::ParseHttpRange("bytes=10-").FormatHttpContentRange(100));
+
+  {
+    std::string s;
+    StorageAccessor::Range::ParseHttpRange("bytes=1-2").Extract(s, "Hello");
+    ASSERT_EQ("el", s);
+    StorageAccessor::Range::ParseHttpRange("bytes=-2").Extract(s, "Hello");
+    ASSERT_EQ("Hel", s);
+    StorageAccessor::Range::ParseHttpRange("bytes=3-").Extract(s, "Hello");
+    ASSERT_EQ("lo", s);
+    StorageAccessor::Range::ParseHttpRange("bytes=-").Extract(s, "Hello");
+    ASSERT_EQ("Hello", s);
+    StorageAccessor::Range::ParseHttpRange("bytes=4-").Extract(s, "Hello");
+    ASSERT_EQ("o", s);
+    ASSERT_THROW(StorageAccessor::Range::ParseHttpRange("bytes=5-").Extract(s, "Hello"), OrthancException);
+  }
+}
\ No newline at end of file
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Mon Nov 25 17:03:47 2024 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Wed Dec 04 18:16:44 2024 +0100
@@ -47,6 +47,8 @@
 #include <boost/math/special_functions/round.hpp>
 #include <boost/shared_ptr.hpp>
 
+#include "../../../OrthancFramework/Sources/FileStorage/StorageAccessor.h"
+
 /**
  * This semaphore is used to limit the number of concurrent HTTP
  * requests on CPU-intensive routes of the REST API, in order to
@@ -442,7 +444,16 @@
     else
     {
       // return the attachment without any transcoding
-      context.AnswerAttachment(call.GetOutput(), publicId, FileContentType_Dicom);
+      FileInfo info;
+      int64_t revision;
+      if (!context.GetIndex().LookupAttachment(info, revision, publicId, FileContentType_Dicom))
+      {
+        throw OrthancException(ErrorCode_UnknownResource);
+      }
+      else
+      {
+        context.AnswerAttachment(call.GetOutput(), info);
+      }
     }
   }
 
@@ -2239,16 +2250,15 @@
   }
 
   
-  static bool GetAttachmentInfo(FileInfo& info,
+  static bool GetAttachmentInfo(FileInfo& info /* out */,
+                                int64_t& revision /* out */,
                                 RestApiGetCall& call)
   {
     CheckValidResourceType(call);
  
     const std::string publicId = call.GetUriComponent("id", "");
-    const std::string name = call.GetUriComponent("name", "");
-    FileContentType contentType = StringToContentType(name);
-
-    int64_t revision;
+    FileContentType contentType = StringToContentType(call.GetUriComponent("name", ""));
+
     if (OrthancRestApi::GetIndex(call).LookupAttachment(info, revision, publicId, contentType))
     {
       SetAttachmentETag(call.GetOutput(), revision, info);  // New in Orthanc 1.9.2
@@ -2291,7 +2301,8 @@
     }
 
     FileInfo info;
-    if (GetAttachmentInfo(info, call))
+    int64_t revision;
+    if (GetAttachmentInfo(info, revision, call))
     {
       Json::Value operations = Json::arrayValue;
 
@@ -2343,7 +2354,8 @@
         .SetUriArgument("name", "The name of the attachment, or its index (cf. `UserContentType` configuration option)")
         .AddAnswerType(MimeType_Binary, "The attachment")
         .SetAnswerHeader("ETag", "Revision of the attachment, to be used in further `PUT` or `DELETE` operations")
-        .SetHttpHeader("If-None-Match", "Optional revision of the metadata, to check if its content has changed");
+        .SetHttpHeader("If-None-Match", "Optional revision of the attachment, to check if its content has changed")
+        .SetHttpHeader("Content-Range", "Optional content range to access part of the attachment (new in Orthanc 1.12.5)");
       return;
     }
 
@@ -2352,37 +2364,54 @@
     CheckValidResourceType(call);
  
     std::string publicId = call.GetUriComponent("id", "");
-    FileContentType type = StringToContentType(call.GetUriComponent("name", ""));
+
+    bool hasRangeHeader = false;
+    StorageAccessor::Range range;
+
+    HttpToolbox::Arguments::const_iterator rangeHeader = call.GetHttpHeaders().find("range");
+    if (rangeHeader != call.GetHttpHeaders().end())
+    {
+      hasRangeHeader = true;
+      range = StorageAccessor::Range::ParseHttpRange(rangeHeader->second);
+    }
 
     FileInfo info;
-    if (GetAttachmentInfo(info, call))
+    int64_t revision;
+    if (GetAttachmentInfo(info, revision, call))
     {
       // NB: "SetAttachmentETag()" is already invoked by "GetAttachmentInfo()"
 
-      if (uncompress)
+      int64_t userRevision;
+      std::string userMD5;
+      if (GetRevisionHeader(userRevision, userMD5, call, "If-None-Match") &&
+          revision == userRevision &&
+          info.GetUncompressedMD5() == userMD5)
+      {
+        call.GetOutput().GetLowLevelOutput().SendStatus(HttpStatus_304_NotModified);
+        return;
+      }
+
+      if (hasRangeHeader)
       {
-        context.AnswerAttachment(call.GetOutput(), publicId, type);
+        std::string fragment;
+        context.ReadAttachmentRange(fragment, info, range, uncompress);
+
+        uint64_t fullSize = (uncompress ? info.GetUncompressedSize() : info.GetCompressedSize());
+        call.GetOutput().GetLowLevelOutput().SetContentType(MimeType_Binary);
+        call.GetOutput().GetLowLevelOutput().AddHeader("Content-Range", range.FormatHttpContentRange(fullSize));
+        call.GetOutput().GetLowLevelOutput().SendStatus(HttpStatus_206_PartialContent, fragment);
+      }
+      else if (uncompress ||
+               info.GetCompressionType() == CompressionType_None)
+      {
+        context.AnswerAttachment(call.GetOutput(), info);
       }
       else
       {
-        // Return the raw data (possibly compressed), as stored on the filesystem
+        // Access to the raw attachment (which is compressed)
         std::string content;
-        std::string attachmentId;
-        int64_t revision;
-        context.ReadAttachment(content, revision, attachmentId, publicId, type, false, true /* skipCache when you absolutely need the compressed data */);
-
-        int64_t userRevision;
-        std::string userMD5;
-        if (GetRevisionHeader(userRevision, userMD5, call, "If-None-Match") &&
-            revision == userRevision &&
-            info.GetUncompressedMD5() == userMD5)
-        {
-          call.GetOutput().GetLowLevelOutput().SendStatus(HttpStatus_304_NotModified);
-        }
-        else
-        {
-          call.GetOutput().AnswerBuffer(content, MimeType_Binary);
-        }
+        context.ReadAttachment(content, info, false /* don't uncompress */, true /* skip cache */);
+        call.GetOutput().AnswerBuffer(content, MimeType_Binary);
       }
     }
   }
@@ -2404,7 +2433,8 @@
     }
 
     FileInfo info;
-    if (GetAttachmentInfo(info, call))
+    int64_t revision;
+    if (GetAttachmentInfo(info, revision, call))
     {
       call.GetOutput().AnswerBuffer(boost::lexical_cast<std::string>(info.GetUncompressedSize()), MimeType_PlainText);
     }
@@ -2427,7 +2457,8 @@
     }
 
     FileInfo info;
-    if (GetAttachmentInfo(info, call))
+    int64_t revision;
+    if (GetAttachmentInfo(info, revision, call))
     {
       Json::Value result = Json::objectValue;    
       result["Uuid"] = info.GetUuid();
@@ -2458,7 +2489,8 @@
     }
 
     FileInfo info;
-    if (GetAttachmentInfo(info, call))
+    int64_t revision;
+    if (GetAttachmentInfo(info, revision, call))
     {
       call.GetOutput().AnswerBuffer(boost::lexical_cast<std::string>(info.GetCompressedSize()), MimeType_PlainText);
     }
@@ -2481,7 +2513,8 @@
     }
 
     FileInfo info;
-    if (GetAttachmentInfo(info, call) &&
+    int64_t revision;
+    if (GetAttachmentInfo(info, revision, call) &&
         info.GetUncompressedMD5() != "")
     {
       call.GetOutput().AnswerBuffer(boost::lexical_cast<std::string>(info.GetUncompressedMD5()), MimeType_PlainText);
@@ -2506,7 +2539,8 @@
     }
 
     FileInfo info;
-    if (GetAttachmentInfo(info, call) &&
+    int64_t revision;
+    if (GetAttachmentInfo(info, revision, call) &&
         info.GetCompressedMD5() != "")
     {
       call.GetOutput().AnswerBuffer(boost::lexical_cast<std::string>(info.GetCompressedMD5()), MimeType_PlainText);
@@ -2551,9 +2585,8 @@
 
     // First check whether the compressed data is correctly stored in the disk
     std::string data;
-    std::string attachmentId;
-
-    context.ReadAttachment(data, revision, attachmentId, publicId, StringToContentType(name), false, true /* skipCache when you absolutely need the compressed data */);
+
+    context.ReadAttachment(data, info, false, true /* skipCache when you absolutely need the compressed data */);
 
     std::string actualMD5;
     Toolbox::ComputeMD5(actualMD5, data);
@@ -2568,7 +2601,7 @@
       }
       else
       {
-        context.ReadAttachment(data, revision, attachmentId, publicId, StringToContentType(name), true, true /* skipCache when you absolutely need the compressed data */);
+        context.ReadAttachment(data, info, true, true /* skipCache when you absolutely need the compressed data */);
         Toolbox::ComputeMD5(actualMD5, data);
         ok = (actualMD5 == info.GetUncompressedMD5());
       }
@@ -2778,7 +2811,8 @@
     }
 
     FileInfo info;
-    if (GetAttachmentInfo(info, call))
+    int64_t revision;
+    if (GetAttachmentInfo(info, revision, call))
     {
       std::string answer = (info.GetCompressionType() == CompressionType_None) ? "0" : "1";
       call.GetOutput().AnswerBuffer(answer, MimeType_PlainText);
--- a/OrthancServer/Sources/ServerContext.cpp	Mon Nov 25 17:03:47 2024 +0100
+++ b/OrthancServer/Sources/ServerContext.cpp	Wed Dec 04 18:16:44 2024 +0100
@@ -958,20 +958,10 @@
 
   
   void ServerContext::AnswerAttachment(RestApiOutput& output,
-                                       const std::string& resourceId,
-                                       FileContentType content)
+                                       const FileInfo& attachment)
   {
-    FileInfo attachment;
-    int64_t revision;
-    if (!index_.LookupAttachment(attachment, revision, resourceId, content))
-    {
-      throw OrthancException(ErrorCode_UnknownResource);
-    }
-    else
-    {
-      StorageAccessor accessor(area_, storageCache_, GetMetricsRegistry());
-      accessor.AnswerFile(output, attachment, GetFileContentMime(content));
-    }
+    StorageAccessor accessor(area_, storageCache_, GetMetricsRegistry());
+    accessor.AnswerFile(output, attachment, GetFileContentMime(attachment.GetContentType()));
   }
 
 
@@ -1212,8 +1202,20 @@
                                 std::string& attachmentId,
                                 const std::string& instancePublicId)
   {
+    FileInfo attachment;
     int64_t revision;
-    ReadAttachment(dicom, revision, attachmentId, instancePublicId, FileContentType_Dicom, true /* uncompress */);
+
+    if (!index_.LookupAttachment(attachment, revision, instancePublicId, FileContentType_Dicom))
+    {
+      throw OrthancException(ErrorCode_InternalError,
+                             "Unable to read attachment " + EnumerationToString(FileContentType_Dicom) +
+                             " of instance " + instancePublicId);
+    }
+
+    assert(attachment.GetContentType() == FileContentType_Dicom);
+    attachmentId = attachment.GetUuid();
+
+    ReadAttachment(dicom, attachment, true /* uncompress */);
   }
 
 
@@ -1288,47 +1290,40 @@
   
 
   void ServerContext::ReadAttachment(std::string& result,
-                                     int64_t& revision,
-                                     std::string& attachmentId,
-                                     const std::string& instancePublicId,
-                                     FileContentType content,
+                                     const FileInfo& attachment,
                                      bool uncompressIfNeeded,
                                      bool skipCache)
   {
-    FileInfo attachment;
-    if (!index_.LookupAttachment(attachment, revision, instancePublicId, content))
-    {
-      throw OrthancException(ErrorCode_InternalError,
-                             "Unable to read attachment " + EnumerationToString(content) +
-                             " of instance " + instancePublicId);
-    }
-
-    assert(attachment.GetContentType() == content);
-    attachmentId = attachment.GetUuid();
-    
-    {
-      std::unique_ptr<StorageAccessor> accessor;
+    std::unique_ptr<StorageAccessor> accessor;
       
-      if (skipCache)
-      {
-        accessor.reset(new StorageAccessor(area_, GetMetricsRegistry()));
-      }
-      else
-      {
-        accessor.reset(new StorageAccessor(area_, storageCache_, GetMetricsRegistry()));
-      }
-
-      if (uncompressIfNeeded)
-      {
-        accessor->Read(result, attachment);
-      }
-      else
-      {
-        // Do not uncompress the content of the storage area, return the
-        // raw data
-        accessor->ReadRaw(result, attachment);
-      }
+    if (skipCache)
+    {
+      accessor.reset(new StorageAccessor(area_, GetMetricsRegistry()));
+    }
+    else
+    {
+      accessor.reset(new StorageAccessor(area_, storageCache_, GetMetricsRegistry()));
+    }
+
+    if (uncompressIfNeeded)
+    {
+      accessor->Read(result, attachment);
     }
+    else
+    {
+      // Do not uncompress the content of the storage area, return the
+      // raw data
+      accessor->ReadRaw(result, attachment);
+    }
+  }
+
+  void ServerContext::ReadAttachmentRange(std::string &result,
+                                          const FileInfo &attachment,
+                                          const StorageAccessor::Range &range,
+                                          bool uncompressIfNeeded)
+  {
+    StorageAccessor accessor(area_, storageCache_, GetMetricsRegistry());
+    accessor.ReadRange(result, attachment, range, uncompressIfNeeded);
   }
 
 
--- a/OrthancServer/Sources/ServerContext.h	Mon Nov 25 17:03:47 2024 +0100
+++ b/OrthancServer/Sources/ServerContext.h	Wed Dec 04 18:16:44 2024 +0100
@@ -38,6 +38,8 @@
 
 #include <boost/date_time/posix_time/posix_time.hpp>
 
+#include "../../OrthancFramework/Sources/FileStorage/StorageAccessor.h"
+
 namespace Orthanc
 {
   class DicomInstanceToStore;
@@ -364,8 +366,7 @@
                                   bool isReconstruct = false);
 
     void AnswerAttachment(RestApiOutput& output,
-                          const std::string& resourceId,
-                          FileContentType content);
+                          const FileInfo& fileInfo);
 
     void ChangeAttachmentCompression(const std::string& resourceId,
                                      FileContentType attachmentType,
@@ -393,13 +394,15 @@
 
     // This method is for low-level operations on "/instances/.../attachments/..."
     void ReadAttachment(std::string& result,
-                        int64_t& revision,
-                        std::string& attachmentId,
-                        const std::string& instancePublicId,
-                        FileContentType content,
+                        const FileInfo& attachment,
                         bool uncompressIfNeeded,
                         bool skipCache = false);
 
+    void ReadAttachmentRange(std::string& result,
+                             const FileInfo& attachment,
+                             const StorageAccessor::Range& range,
+                             bool uncompressIfNeeded);
+
     void SetStoreMD5ForAttachments(bool storeMD5);
 
     bool IsStoreMD5ForAttachments() const