changeset 5917:fa8c10f10312 get-scu

merged default -> get-scu
author Alain Mazy <am@orthanc.team>
date Mon, 09 Dec 2024 19:41:24 +0100
parents 305d318f488d (current diff) cc5a6f3b9bbe (diff)
children
files NEWS OrthancServer/Sources/ServerContext.cpp OrthancServer/Sources/ServerContext.h TODO
diffstat 17 files changed, 690 insertions(+), 161 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Mon Dec 09 19:41:02 2024 +0100
+++ b/NEWS	Mon Dec 09 19:41:24 2024 +0100
@@ -17,6 +17,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
@@ -49,7 +51,10 @@
 * Fix C-Find queries not returning private tags in the modality worklist plugin.
 * Fix extremely rare error when 2 threads are trying to create the same folder in the File Storage 
   at the same time.
-* Fix crashes if handling very large images
+* Fix crashes if handling very large images.
+* Fix deadlock when parsing specific invalid DICOM files.
+* Loading plugins:
+  - Orthanc will now fail to start when provided with a plugin path that can not be found.
 * Metrics:
   - fix a few metrics that were not published
   - added 2 metrics: orthanc_storage_cache_miss_count & orthanc_storage_cache_hit_count 
@@ -85,7 +90,7 @@
 * Housekeeper plugin: 
   - Added an option "LimitMainDicomTagsReconstructLevel"
     (allowed values: "Patient", "Study", "Series", "Instance"). This can greatly speed
-    up the housekeeper process, e.g. if you have only update the Study level ExtraMainDicomTags.
+    up the housekeeper process, e.g. if you have only updated the Study level ExtraMainDicomTags.
   - Fixed broken /instances/../tags route after running the Housekeeper
     after having changed the "IngestTranscoding".
 * SDK: added OrthancPluginLogMessage() as a new primitive for plugins
--- a/OrthancFramework/Resources/Patches/dcmtk-3.6.8.patch	Mon Dec 09 19:41:02 2024 +0100
+++ b/OrthancFramework/Resources/Patches/dcmtk-3.6.8.patch	Mon Dec 09 19:41:24 2024 +0100
@@ -1,6 +1,6 @@
 diff -urEb dcmtk-DCMTK-3.6.8.orig/CMake/GenerateDCMTKConfigure.cmake dcmtk-DCMTK-3.6.8/CMake/GenerateDCMTKConfigure.cmake
---- dcmtk-DCMTK-3.6.8.orig/CMake/GenerateDCMTKConfigure.cmake	2024-01-09 17:13:10.329673608 +0100
-+++ dcmtk-DCMTK-3.6.8/CMake/GenerateDCMTKConfigure.cmake	2024-01-09 18:21:52.568142681 +0100
+--- dcmtk-DCMTK-3.6.8.orig/CMake/GenerateDCMTKConfigure.cmake	2023-12-19 11:12:57.000000000 +0100
++++ dcmtk-DCMTK-3.6.8/CMake/GenerateDCMTKConfigure.cmake	2024-11-25 16:54:59.036009112 +0100
 @@ -224,6 +224,8 @@
  
  # Check the sizes of various types
@@ -18,9 +18,11 @@
  
  # Check for include files, libraries, and functions
  include("${DCMTK_CMAKE_INCLUDE}CMake/dcmtkTryCompile.cmake")
+Only in dcmtk-DCMTK-3.6.8/config/include/dcmtk/config: arith.h
+Only in dcmtk-DCMTK-3.6.8/config/include/dcmtk/config: osconfig.h
 diff -urEb dcmtk-DCMTK-3.6.8.orig/dcmdata/include/dcmtk/dcmdata/dcdict.h dcmtk-DCMTK-3.6.8/dcmdata/include/dcmtk/dcmdata/dcdict.h
---- dcmtk-DCMTK-3.6.8.orig/dcmdata/include/dcmtk/dcmdata/dcdict.h	2024-01-09 17:13:10.337673529 +0100
-+++ dcmtk-DCMTK-3.6.8/dcmdata/include/dcmtk/dcmdata/dcdict.h	2024-01-09 18:21:52.568142681 +0100
+--- dcmtk-DCMTK-3.6.8.orig/dcmdata/include/dcmtk/dcmdata/dcdict.h	2023-12-19 11:12:57.000000000 +0100
++++ dcmtk-DCMTK-3.6.8/dcmdata/include/dcmtk/dcmdata/dcdict.h	2024-11-25 16:54:59.036009112 +0100
 @@ -162,6 +162,12 @@
      /// returns an iterator to the end of the repeating tag dictionary
      DcmDictEntryListIterator repeatingEnd() { return repDict.end(); }
@@ -35,17 +37,18 @@
  
      /** private undefined assignment operator
 diff -urEb dcmtk-DCMTK-3.6.8.orig/dcmdata/libsrc/dcdict.cc dcmtk-DCMTK-3.6.8/dcmdata/libsrc/dcdict.cc
---- dcmtk-DCMTK-3.6.8.orig/dcmdata/libsrc/dcdict.cc	2024-01-09 17:13:10.337673529 +0100
-+++ dcmtk-DCMTK-3.6.8/dcmdata/libsrc/dcdict.cc	2024-01-09 18:21:52.568142681 +0100
+--- dcmtk-DCMTK-3.6.8.orig/dcmdata/libsrc/dcdict.cc	2023-12-19 11:12:57.000000000 +0100
++++ dcmtk-DCMTK-3.6.8/dcmdata/libsrc/dcdict.cc	2024-11-25 16:54:59.036009112 +0100
 @@ -914,3 +914,5 @@
    wrlock().clear();
    wrunlock();
  }
 +
 +#include "dcdict_orthanc.cc"
+Only in dcmtk-DCMTK-3.6.8/dcmdata/libsrc: dcdict_orthanc.cc
 diff -urEb dcmtk-DCMTK-3.6.8.orig/dcmdata/libsrc/dcpxitem.cc dcmtk-DCMTK-3.6.8/dcmdata/libsrc/dcpxitem.cc
---- dcmtk-DCMTK-3.6.8.orig/dcmdata/libsrc/dcpxitem.cc	2024-01-09 17:13:10.337673529 +0100
-+++ dcmtk-DCMTK-3.6.8/dcmdata/libsrc/dcpxitem.cc	2024-01-09 18:21:52.568142681 +0100
+--- dcmtk-DCMTK-3.6.8.orig/dcmdata/libsrc/dcpxitem.cc	2023-12-19 11:12:57.000000000 +0100
++++ dcmtk-DCMTK-3.6.8/dcmdata/libsrc/dcpxitem.cc	2024-11-25 16:54:59.036009112 +0100
 @@ -31,6 +31,9 @@
  #include "dcmtk/dcmdata/dcostrma.h"    /* for class DcmOutputStream */
  #include "dcmtk/dcmdata/dcwcache.h"    /* for class DcmWriteCache */
@@ -57,8 +60,8 @@
  // ********************************
  
 diff -urEb dcmtk-DCMTK-3.6.8.orig/dcmnet/libsrc/scu.cc dcmtk-DCMTK-3.6.8/dcmnet/libsrc/scu.cc
---- dcmtk-DCMTK-3.6.8.orig/dcmnet/libsrc/scu.cc	2024-01-09 17:13:10.349673411 +0100
-+++ dcmtk-DCMTK-3.6.8/dcmnet/libsrc/scu.cc	2024-01-09 18:23:08.723435667 +0100
+--- dcmtk-DCMTK-3.6.8.orig/dcmnet/libsrc/scu.cc	2023-12-19 11:12:57.000000000 +0100
++++ dcmtk-DCMTK-3.6.8/dcmnet/libsrc/scu.cc	2024-11-25 16:54:59.036009112 +0100
 @@ -19,6 +19,11 @@
   *
   */
@@ -72,8 +75,8 @@
  
  #include "dcmtk/dcmdata/dcostrmf.h" /* for class DcmOutputFileStream */
 diff -urEb dcmtk-DCMTK-3.6.8.orig/oflog/include/dcmtk/oflog/thread/syncpub.h dcmtk-DCMTK-3.6.8/oflog/include/dcmtk/oflog/thread/syncpub.h
---- dcmtk-DCMTK-3.6.8.orig/oflog/include/dcmtk/oflog/thread/syncpub.h	2024-01-09 17:13:10.389673016 +0100
-+++ dcmtk-DCMTK-3.6.8/oflog/include/dcmtk/oflog/thread/syncpub.h	2024-01-09 18:21:52.568142681 +0100
+--- dcmtk-DCMTK-3.6.8.orig/oflog/include/dcmtk/oflog/thread/syncpub.h	2023-12-19 11:12:57.000000000 +0100
++++ dcmtk-DCMTK-3.6.8/oflog/include/dcmtk/oflog/thread/syncpub.h	2024-11-25 16:54:59.037009100 +0100
 @@ -63,7 +63,7 @@
  
  DCMTK_LOG4CPLUS_INLINE_EXPORT
@@ -111,8 +114,8 @@
  
  
 diff -urEb dcmtk-DCMTK-3.6.8.orig/oflog/libsrc/oflog.cc dcmtk-DCMTK-3.6.8/oflog/libsrc/oflog.cc
---- dcmtk-DCMTK-3.6.8.orig/oflog/libsrc/oflog.cc	2024-01-09 17:13:10.389673016 +0100
-+++ dcmtk-DCMTK-3.6.8/oflog/libsrc/oflog.cc	2024-01-09 18:21:52.568142681 +0100
+--- dcmtk-DCMTK-3.6.8.orig/oflog/libsrc/oflog.cc	2023-12-19 11:12:57.000000000 +0100
++++ dcmtk-DCMTK-3.6.8/oflog/libsrc/oflog.cc	2024-11-25 16:54:59.037009100 +0100
 @@ -19,6 +19,11 @@
   *
   */
@@ -126,8 +129,8 @@
  #include "dcmtk/oflog/oflog.h"
  
 diff -urEb dcmtk-DCMTK-3.6.8.orig/ofstd/include/dcmtk/ofstd/offile.h dcmtk-DCMTK-3.6.8/ofstd/include/dcmtk/ofstd/offile.h
---- dcmtk-DCMTK-3.6.8.orig/ofstd/include/dcmtk/ofstd/offile.h	2024-01-09 17:13:10.389673016 +0100
-+++ dcmtk-DCMTK-3.6.8/ofstd/include/dcmtk/ofstd/offile.h	2024-01-09 18:21:52.568142681 +0100
+--- dcmtk-DCMTK-3.6.8.orig/ofstd/include/dcmtk/ofstd/offile.h	2023-12-19 11:12:57.000000000 +0100
++++ dcmtk-DCMTK-3.6.8/ofstd/include/dcmtk/ofstd/offile.h	2024-11-25 16:54:59.037009100 +0100
 @@ -570,7 +570,7 @@
     */
    void setlinebuf()
@@ -137,3 +140,18 @@
      this->setvbuf(NULL, _IOLBF, 0);
  #else
      :: setlinebuf(file_);
+diff -urEb dcmtk-DCMTK-3.6.8.orig/ofstd/include/dcmtk/ofstd/ofutil.h dcmtk-DCMTK-3.6.8/ofstd/include/dcmtk/ofstd/ofutil.h
+--- dcmtk-DCMTK-3.6.8.orig/ofstd/include/dcmtk/ofstd/ofutil.h	2023-12-19 11:12:57.000000000 +0100
++++ dcmtk-DCMTK-3.6.8/ofstd/include/dcmtk/ofstd/ofutil.h	2024-11-25 17:00:27.525244000 +0100
+@@ -75,8 +75,8 @@
+         // copy constructor should be fine for primitive types.
+         inline type(const T& pt)
+         : t( pt ) {}
+-        inline type(const OFrvalue_storage& rhs)
+-        : t( rhs.pt ) {}
++        inline type(const type& rhs)
++        : t( rhs.t ) {}
+ 
+         // automatic conversion to the underlying type
+         inline operator T&() const { return OFconst_cast( T&, t ); }
+Only in dcmtk-DCMTK-3.6.8/ofstd/include/dcmtk/ofstd: ofutil.h~
--- a/OrthancFramework/Sources/DicomFormat/DicomStreamReader.cpp	Mon Dec 09 19:41:02 2024 +0100
+++ b/OrthancFramework/Sources/DicomFormat/DicomStreamReader.cpp	Mon Dec 09 19:41:24 2024 +0100
@@ -247,6 +247,10 @@
           
         pos += length + 12;
       }
+      else
+      {
+        throw OrthancException(ErrorCode_BadFileFormat, "Invalid DICOM File: Unable to parse Meta Header");
+      }
     }
 
     if (pos != block.size())
--- a/OrthancFramework/Sources/FileStorage/StorageAccessor.cpp	Mon Dec 09 19:41:02 2024 +0100
+++ b/OrthancFramework/Sources/FileStorage/StorageAccessor.cpp	Mon Dec 09 19:41:24 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 Dec 09 19:41:02 2024 +0100
+++ b/OrthancFramework/Sources/FileStorage/StorageAccessor.h	Mon Dec 09 19:41:24 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 Dec 09 19:41:02 2024 +0100
+++ b/OrthancFramework/Sources/HttpServer/HttpOutput.cpp	Mon Dec 09 19:41:24 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 Dec 09 19:41:02 2024 +0100
+++ b/OrthancFramework/Sources/HttpServer/HttpOutput.h	Mon Dec 09 19:41:24 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/Sources/Lua/LuaContext.h	Mon Dec 09 19:41:02 2024 +0100
+++ b/OrthancFramework/Sources/Lua/LuaContext.h	Mon Dec 09 19:41:24 2024 +0100
@@ -124,5 +124,17 @@
                                       lua_State* state,
                                       int top,
                                       bool keyToLowerCase);
+
+#if ORTHANC_ENABLE_CURL == 1
+    void SetHttpsVerifyPeers(bool verify)
+    {
+      httpClient_.SetHttpsVerifyPeers(verify);
+    }
+
+    bool IsHttpsVerifyPeers() const
+    {
+      return httpClient_.IsHttpsVerifyPeers();
+    }
+#endif
   };
 }
--- a/OrthancFramework/UnitTestsSources/FileStorageTests.cpp	Mon Dec 09 19:41:02 2024 +0100
+++ b/OrthancFramework/UnitTestsSources/FileStorageTests.cpp	Mon Dec 09 19:41:24 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/OrthancFramework/UnitTestsSources/RestApiTests.cpp	Mon Dec 09 19:41:02 2024 +0100
+++ b/OrthancFramework/UnitTestsSources/RestApiTests.cpp	Mon Dec 09 19:41:24 2024 +0100
@@ -73,19 +73,24 @@
   ASSERT_TRUE(c.IsVerbose());
   c.SetVerbose(false);
   ASSERT_FALSE(c.IsVerbose());
+  ASSERT_TRUE(c.IsRedirectionFollowed());
+  c.SetRedirectionFollowed(false);
+  ASSERT_FALSE(c.IsRedirectionFollowed());
 
 #if UNIT_TESTS_WITH_HTTP_CONNEXIONS == 1
-  // The "http://www.orthanc-server.com/downloads/third-party/" does
-  // not automatically redirect to HTTPS, so we cas use it even if the
-  // OpenSSL/HTTPS support is disabled in curl
-  const std::string BASE = "http://www.orthanc-server.com/downloads/third-party/";
+  // The "http://httpbin.org/get" URL does not automatically redirect
+  // to HTTPS, so we can use it even if the OpenSSL/HTTPS support is
+  // disabled in curl
 
+  const std::string URL = "http://httpbin.org/get";
+  
   Json::Value v;
-  c.SetUrl(BASE + "Product.json");
+  c.SetUrl(URL);
 
   c.Apply(v);
   ASSERT_TRUE(v.type() == Json::objectValue);
-  ASSERT_TRUE(v.isMember("Description"));
+  ASSERT_TRUE(v.isMember("url"));
+  ASSERT_EQ(URL, v["url"].asString());
 #endif
 }
 #endif
--- a/OrthancServer/Plugins/Engine/PluginsManager.cpp	Mon Dec 09 19:41:02 2024 +0100
+++ b/OrthancServer/Plugins/Engine/PluginsManager.cpp	Mon Dec 09 19:41:24 2024 +0100
@@ -244,8 +244,21 @@
   {
     if (!boost::filesystem::exists(path))
     {
-      LOG(ERROR) << "Inexistent path to plugins: " << path;
-      return;
+      boost::filesystem::path p(path);
+      std::string extension = p.extension().string();
+      Toolbox::ToLowerCase(extension);
+
+      if (extension == PLUGIN_EXTENSION)
+      { 
+        // if this is a plugin path, fail to start
+        throw OrthancException(ErrorCode_SharedLibrary, "Inexistent path to plugin: " + path);
+      }
+      else
+      { 
+        // it might be a directory -> just log a warning
+        LOG(WARNING) << "Inexistent path to plugins: " << path;
+        return;
+      }
     }
 
     if (boost::filesystem::is_directory(path))
--- a/OrthancServer/Plugins/Samples/Housekeeper/Plugin.cpp	Mon Dec 09 19:41:02 2024 +0100
+++ b/OrthancServer/Plugins/Samples/Housekeeper/Plugin.cpp	Mon Dec 09 19:41:24 2024 +0100
@@ -859,12 +859,15 @@
               "StorageCompressionChange": true,
               "MainDicomTagsChange": true,
               "UnnecessaryDicomAsJsonFiles": true,
+              "IngestTranscodingChange": true,
               "DicomWebCacheChange": true   // new in 1.12.2
             },
 
-            // When rebuilding MainDicomTags, limit to a single level of resource.
-            // Allowed values: "Patient", "Study", "Series", "Instance"
-            "LimitMainDicomTagsReconstructLevel": "Study"
+            // When rebuilding MainDicomTags, limit to a single level of resource
+            // which can greatly improve performances e.g. if you have only updated 
+            // the Study level ExtraMainDicomTags.
+            // Allowed values: "Patient", "Study", "Series", "Instance", "All"
+            "LimitMainDicomTagsReconstructLevel": "All"
 
           }
         }
@@ -887,11 +890,12 @@
         triggerOnDicomWebCacheChange_ = triggers.GetBooleanValue("DicomWebCacheChange", true);
       }
 
-      limitMainDicomTagsReconstructLevel_ = housekeeper.GetStringValue("LimitMainDicomTagsReconstructLevel", "");
+      limitMainDicomTagsReconstructLevel_ = housekeeper.GetStringValue("LimitMainDicomTagsReconstructLevel", "All");
       if (limitMainDicomTagsReconstructLevel_ != "Patient" && limitMainDicomTagsReconstructLevel_ != "Study"
-        && limitMainDicomTagsReconstructLevel_ != "Series" && limitMainDicomTagsReconstructLevel_ != "Instance")
+        && limitMainDicomTagsReconstructLevel_ != "Series" && limitMainDicomTagsReconstructLevel_ != "Instance" && limitMainDicomTagsReconstructLevel_ != "All")
       {
         ORTHANC_PLUGINS_LOG_ERROR("Housekeeper invalid value for 'LimitMainDicomTagsReconstructLevel': '" + limitMainDicomTagsReconstructLevel_ + "'");
+        return -1;
       }
       else if (limitMainDicomTagsReconstructLevel_ == "Patient")
       {
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Mon Dec 09 19:41:02 2024 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Mon Dec 09 19:41:24 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 Dec 09 19:41:02 2024 +0100
+++ b/OrthancServer/Sources/ServerContext.cpp	Mon Dec 09 19:41:24 2024 +0100
@@ -970,20 +970,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()));
   }
 
 
@@ -1224,8 +1214,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 */);
   }
 
 
@@ -1300,47 +1302,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 Dec 09 19:41:02 2024 +0100
+++ b/OrthancServer/Sources/ServerContext.h	Mon Dec 09 19:41:24 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;
@@ -366,8 +368,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,
@@ -395,13 +396,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
--- a/OrthancServer/UnitTestsSources/LuaServerTests.cpp	Mon Dec 09 19:41:02 2024 +0100
+++ b/OrthancServer/UnitTestsSources/LuaServerTests.cpp	Mon Dec 09 19:41:24 2024 +0100
@@ -138,38 +138,43 @@
 TEST(Lua, Http)
 {
   Orthanc::LuaContext lua;
-
-#if UNIT_TESTS_WITH_HTTP_CONNEXIONS == 1
-  // The "http://www.orthanc-server.com/downloads/third-party/" does
-  // not automatically redirect to HTTPS, so we use it even if the
-  // OpenSSL/HTTPS support is disabled in curl
-  const std::string BASE = "http://www.orthanc-server.com/downloads/third-party/";
-
-#if LUA_VERSION_NUM >= 502
-  // Since Lua >= 5.2.0, the function "loadstring" has been replaced by "load"
-  lua.Execute("JSON = load(HttpGet('" + BASE + "JSON.lua')) ()");
-#else
-  lua.Execute("JSON = loadstring(HttpGet('" + BASE + "JSON.lua')) ()");
-#endif
-
-  const std::string url(BASE + "Product.json");
-#endif
+  ASSERT_TRUE(lua.IsHttpsVerifyPeers());
+  lua.SetHttpsVerifyPeers(false);
+  ASSERT_FALSE(lua.IsHttpsVerifyPeers());
 
   std::string s;
   lua.Execute(s, "print(HttpGet({}))");
   ASSERT_EQ("nil", Orthanc::Toolbox::StripSpaces(s));
 
-#if UNIT_TESTS_WITH_HTTP_CONNEXIONS == 1  
-  lua.Execute(s, "print(string.len(HttpGet(\"" + url + "\")))");
-  ASSERT_LE(100, boost::lexical_cast<int>(Orthanc::Toolbox::StripSpaces(s)));
+#if UNIT_TESTS_WITH_HTTP_CONNEXIONS == 1
+  // The "http://httpbin.org/get" URL does not automatically redirect
+  // to HTTPS, so we can use it even if the OpenSSL/HTTPS support is
+  // disabled in curl
 
-  // Parse a JSON file
-  lua.Execute(s, "print(JSON:decode(HttpGet(\"" + url + "\")) ['Product'])");
-  ASSERT_EQ("OrthancClient", Orthanc::Toolbox::StripSpaces(s));
+  const std::string URL = "http://httpbin.org/get";
+  lua.Execute(s, "print(HttpGet(\"" + URL + "\"))");
+
+  Json::Value json;
+  Orthanc::Toolbox::ReadJson(json, s);
+
+  ASSERT_TRUE(json.type() == Json::objectValue);
+  ASSERT_TRUE(json.isMember("url"));
+  ASSERT_EQ(URL, json["url"].asString());
 
 #if 0
   // This part of the test can only be executed if one instance of
-  // Orthanc is running on the localhost
+  // Orthanc is running on the localhost, with configuration option
+  // "ExecuteLuaEnabled" equals to "true"
+
+  const std::string JSON_LUA_TOOLBOX = "https://regex.info/code/JSON.lua";
+  lua.Execute("HttpGet('" + JSON_LUA_TOOLBOX + "')");
+
+#if LUA_VERSION_NUM >= 502
+  // Since Lua >= 5.2.0, the function "loadstring" has been replaced by "load"
+  lua.Execute("JSON = load(HttpGet('" + JSON_LUA_TOOLBOX + "')) ()");
+#else
+  lua.Execute("JSON = loadstring(HttpGet('" + JSON_LUA_TOOLBOX + "')) ()");
+#endif
 
   lua.Execute("modality = {}");
   lua.Execute("table.insert(modality, 'ORTHANC')");
--- a/TODO	Mon Dec 09 19:41:02 2024 +0100
+++ b/TODO	Mon Dec 09 19:41:24 2024 +0100
@@ -297,8 +297,22 @@
   https://groups.google.com/g/orthanc-users/c/ymtaAmgSs6Q/m/PqVBactQAQAJ
 * Add an index on the UUID column in the DelayedDeletion plugin:
   https://discourse.orthanc-server.org/t/delayeddeletion-improvement-unique-index-on-pending-uuid-column/4032
+* Orthanc shall refuse to start if one registers 2 storage plugins.
+  Right now, this is not possible because OrthancPluginRegisterStorageArea2 does not return any value
+  and it can not throw an Exception because that's a core function called from a plugin -> the Exception
+  can not cross the C/C++ frontier safely -> we need a OrthancPluginRegisterStorageArea3 with a return value.
+  Ex: install DelayedDeletion + S3 storage.  Right now, the second plugin to load is just ignored with an error
+  message in the logs.
 
 
+-----------
+Housekeeper
+-----------
+
+* The Housekeeper should just refuse to start if you are using a lossy transfer syntax because that would generate 
+  new orthanc ids as well and there is a risk of messing up things.  tools/reconstruct shall return a 400 as well.
+  https://discourse.orthanc-server.org/t/crashes-on-housekeeper-transcode-storing-in-s3/5330/2
+
 ----------------
 Ideas of plugins
 ----------------