view Sources/EducationRestApi.cpp @ 77:80b663d5f8fe default tip

replaced boost::math::iround() by Orthanc::Math::llround()
author Sebastien Jodogne <s.jodogne@gmail.com>
date Tue, 27 Jan 2026 17:05:03 +0100
parents 0f8c46d755e2
children
line wrap: on
line source

/**
 * SPDX-FileCopyrightText: 2024-2026 Sebastien Jodogne, EPL UCLouvain, Belgium
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

/**
 * Orthanc for Education
 * Copyright (C) 2024-2026 Sebastien Jodogne, EPL UCLouvain, Belgium
 *
 * This program is free software: you can redistribute it and/or
 * modify it under the terms of the GNU Affero 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
 * Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 **/


#include "EducationRestApi.h"

#include "Dicomization/ActiveUploads.h"
#include "Dicomization/DicomizationJob.h"
#include "Dicomization/WholeSlideImagingDicomizer.h"
#include "EducationConfiguration.h"
#include "LTI/LTIRoutes.h"
#include "OrthancDatabase.h"
#include "ProjectPermissionContext.h"
#include "RestApiRouter.h"

#include <CompatibilityMath.h>
#include <Images/Image.h>
#include <Images/ImageProcessing.h>
#include <MultiThreading/Semaphore.h>
#include <SerializationToolbox.h>
#include <SystemToolbox.h>
#include <TemporaryFile.h>

#include <boost/algorithm/string/predicate.hpp>
#include <boost/regex.hpp>
#include <cassert>


static const char* const COOKIE_USER_AUTH = "orthanc-education-user";


template <bool AdministratorOnly>
void ServeWebApplication(OrthancPluginRestOutput* output,
                         const std::string& url,
                         const OrthancPluginHttpRequest* request,
                         const AuthenticatedUser& user)
{
  if (AdministratorOnly)
  {
    assert(user.GetRole() == Role_Administrator);
  }
  else
  {
    assert(user.GetRole() == Role_Guest);
  }

  static const std::string PREFIX = "/education/app/";
  if (!boost::starts_with(url, PREFIX))
  {
    throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
  }

  const std::string filename = std::string(url).substr(PREFIX.length());
  const Orthanc::MimeType mime = Orthanc::SystemToolbox::AutodetectMimeType(filename);

  std::string content;
  HttpToolbox::GetWebApplicationResource(content, filename);

  OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output,
                            content.c_str(), content.size(), Orthanc::EnumerationToString(mime));
}


void DoLogin(OrthancPluginRestOutput* output,
             const std::string& url,
             const OrthancPluginHttpRequest* request,
             const AuthenticatedUser& oldUser,
             const Json::Value& body)
{
  assert(oldUser.GetRole() == Role_Guest);

  const std::string username = Orthanc::SerializationToolbox::ReadString(body, "username");
  const std::string password = Orthanc::SerializationToolbox::ReadString(body, "password");

  std::unique_ptr<AuthenticatedUser> user(EducationConfiguration::GetInstance().DoLoginAuthentication(username, password));

  if (user.get() == NULL)
  {
    throw Orthanc::OrthancException(Orthanc::ErrorCode_Unauthorized);
  }
  else
  {
    std::string token;
    user->ForgeJWT(token, EducationConfiguration::GetInstance().GetLtiContext(),
                   EducationConfiguration::GetInstance().GetMaxLoginAgeSeconds());

    {
      const bool secureCookies = EducationConfiguration::GetInstance().IsSecureCookies();
      EducationConfiguration::GetInstance().GetLtiContext().CloseSession(output, secureCookies);
      HttpToolbox::SetCookie(output, COOKIE_USER_AUTH, token, CookieSameSite_Lax, secureCookies);
    }

    Json::Value answer;
    HttpToolbox::AnswerJson(output, answer);
  }
}


void DoLogout(OrthancPluginRestOutput* output,
              const std::string& url,
              const OrthancPluginHttpRequest* request,
              const AuthenticatedUser& user)
{
  assert(user.GetRole() == Role_Guest);

  {
    const bool secureCookies = EducationConfiguration::GetInstance().IsSecureCookies();
    EducationConfiguration::GetInstance().GetLtiContext().CloseSession(output, secureCookies);
    HttpToolbox::ClearCookie(output, COOKIE_USER_AUTH, CookieSameSite_Lax, secureCookies);
  }

  ClearLTICookie(output);

  // We manually reimplement "OrthancPluginRedirect()", otherwise "Set-Cookie" has no effect
  OrthancPluginSetHttpHeader(OrthancPlugins::GetGlobalContext(), output, "Location", ".." /* redirect to the root */);
  OrthancPluginSendHttpStatusCode(OrthancPlugins::GetGlobalContext(), output, 302);
}


void GenerateListProjectUrl(OrthancPluginRestOutput* output,
                            const std::string& url,
                            const OrthancPluginHttpRequest* request,
                            const AuthenticatedUser& user,
                            const Json::Value& body)
{
  assert(user.GetRole() == Role_Guest);

  const std::string projectId = Orthanc::SerializationToolbox::ReadString(body, "project");

  std::string s;
  Orthanc::Toolbox::UriEncode(s, projectId);

  const std::string listUrl = "education/app/list-projects.html?open-project-id=" + s;

  Json::Value answer;

  // NB: "relative_url" is relative to the root of Orthanc
  answer["relative_url"] = listUrl;

  std::string absolute;
  if (EducationConfiguration::GetInstance().GetAbsoluteUrl(absolute, listUrl))
  {
    answer["absolute_url"] = absolute;
  }

  HttpToolbox::AnswerJson(output, answer);
}


void GenerateViewerUrlFromResource(OrthancPluginRestOutput* output,
                                   const std::string& url,
                                   const OrthancPluginHttpRequest* request,
                                   const AuthenticatedUser& user,
                                   const Json::Value& body)
{
  assert(user.GetRole() == Role_Guest);

  const ViewerType viewer = ParseViewerType(Orthanc::SerializationToolbox::ReadString(body, "viewer"));

  Json::Value resource;
  if (!HttpToolbox::LookupJsonObject(resource, body, "resource"))
  {
    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
  }

  const std::string viewerUrl = OrthancDatabase::GenerateViewerUrl(viewer, resource);

  Json::Value answer;

  // NB: "relative_url" is relative to the root of Orthanc
  answer["relative_url"] = viewerUrl;

  std::string absolute;
  if (EducationConfiguration::GetInstance().GetAbsoluteUrl(absolute, viewerUrl))
  {
    answer["absolute_url"] = absolute;
  }

  HttpToolbox::AnswerJson(output, answer);
}


void GetUserProjects(OrthancPluginRestOutput* output,
                     const std::string& url,
                     const OrthancPluginHttpRequest* request,
                     const AuthenticatedUser& user)
{
  // List all the projects
  Json::Value projects = Json::objectValue;

  {
    DocumentOrientedDatabase::Iterator iterator(ProjectPermissionContext::GetProjects());

    while (iterator.Next())
    {
      const Project& project = iterator.GetDocument<Project>();
      const ProjectAccessMode mode = ProjectPermissionContext::GetProjectAccessMode(user, iterator.GetKey(), project);

      Json::Value item;

      if (mode == ProjectAccessMode_Writable)
      {
        item["role"] = "instructor";
      }
      else if (EducationConfiguration::GetInstance().IsListProjectsAsLearner() &&
               mode == ProjectAccessMode_ReadOnly)
      {
        item["role"] = "learner";
      }
      else
      {
        continue;
      }

      OrthancDatabase::FormatProjectWithResources(item, iterator.GetKey(), project);
      projects[iterator.GetKey()] = item;
    }
  }

  Json::Value answer = Json::objectValue;
  answer["projects"] = projects;

  HttpToolbox::AnswerJson(output, answer);
}


static const char* const GetHomepage(Role role)
{
  switch (role)
  {
    case Role_Administrator:
      // Redirect to the dashboard if the user is already logged as an administrator
      return "education/app/dashboard.html";

    case Role_Standard:
      return "education/app/list-projects.html";

    case Role_Guest:
      // By default, redirect to the login page
      return "education/app/login.html";

    default:
      throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
  }
}


void RedirectRoot(OrthancPluginRestOutput* output,
                  const std::string& url,
                  const OrthancPluginHttpRequest* request,
                  const AuthenticatedUser& user)
{
  const std::string homepage = GetHomepage(user.GetRole());
  OrthancPluginRedirect(OrthancPlugins::GetGlobalContext(), output, homepage.c_str());
}


void ServeConfiguration(OrthancPluginRestOutput* output,
                        const std::string& url,
                        const OrthancPluginHttpRequest* request,
                        const AuthenticatedUser& user)
{
  Json::Value config;
  user.Serialize(config["user"]);

  if (user.GetRole() == Role_Administrator)
  {
    // This information is available to "dashboard.html", but not to "list-projects.html"
    config["has_orthanc_explorer_2"] = EducationConfiguration::GetInstance().HasPluginOrthancExplorer2();
    config["has_wsi_dicomizer"] = !EducationConfiguration::GetInstance().GetPathToWsiDicomizer().empty();
    config["lti_enabled"] = EducationConfiguration::GetInstance().IsLtiEnabled();
    config["lti_client_id"] = EducationConfiguration::GetInstance().GetLtiClientId();
    config["lti_platform_url"] = EducationConfiguration::GetInstance().GetLtiPlatformUrl();
    config["lti_platform_keys_url"] = EducationConfiguration::GetInstance().GetLtiPlatformKeysUrl();
    config["lti_platform_redirection_url"] = EducationConfiguration::GetInstance().GetLtiPlatformRedirectionUrl();
  }

  std::set<ViewerType> viewers;
  EducationConfiguration::GetInstance().ListAvailableViewers(viewers);
  HttpToolbox::FormatViewers(config["viewers"], viewers);

  if (viewers.empty() ||
      viewers.find(ViewerType_StoneWebViewer) != viewers.end())
  {
    config["default_viewer"] = EnumerationToString(ViewerType_StoneWebViewer);
  }
  else
  {
    config["default_viewer"] = EnumerationToString(*viewers.begin());
  }

  config["label_prefix"] = LABEL_PREFIX;

  HttpToolbox::AnswerJson(output, config);
}


void ServeLogin(OrthancPluginRestOutput* output,
                const std::string& url,
                const OrthancPluginHttpRequest* request,
                const AuthenticatedUser& user)
{
  if (user.GetRole() == Role_Administrator ||
      user.GetRole() == Role_Standard)
  {
    // Special case: If the user is already logged in, redirect to the homepage
    const std::string redirection = Orthanc::Toolbox::JoinUri("../..", GetHomepage(user.GetRole()));
    OrthancPluginRedirect(OrthancPlugins::GetGlobalContext(), output, redirection.c_str());
  }
  else
  {
    std::string content;
    HttpToolbox::GetWebApplicationResource(content, "login.html");

    OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output,
                              content.c_str(), content.size(), Orthanc::EnumerationToString(Orthanc::MimeType_Html));
  }
}


static std::string GetJsonString(const Json::Value& json)
{
  if (json.type() == Json::stringValue)
  {
    return json.asString();
  }
  else
  {
    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
  }
}


void ChangeProjectParameter(OrthancPluginRestOutput* output,
                            const std::string& url,
                            const OrthancPluginHttpRequest* request,
                            const AuthenticatedUser& user)
{
  const std::string key(request->groups[0]);
  const std::string property(request->groups[1]);

  std::unique_ptr<Project> project(ProjectPermissionContext::GetProjects().CloneDocument<Project>(key));

  if (ProjectPermissionContext::GetProjectAccessMode(user, key, *project) != ProjectAccessMode_Writable)
  {
    throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess);
  }

  if (user.GetRole() != Role_Administrator &&
      property != "policy" &&
      property != "primary-viewer" &&
      property != "secondary-viewers")
  {
    // Other properties can only be changed by the administrator
    throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess);
  }

  if (request->method == OrthancPluginHttpMethod_Put)
  {
    Json::Value body;
    if (!Orthanc::Toolbox::ReadJson(body, request->body, request->bodySize))
    {
      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
    }
    else
    {
      if (property == "name")
      {
        project->SetName(GetJsonString(body));
      }
      else if (property == "description")
      {
        project->SetDescription(GetJsonString(body));
      }
      else if (property == "policy")
      {
        project->SetPolicy(ParseProjectPolicy(GetJsonString(body)));
      }
      else if (property == "primary-viewer")
      {
        project->SetPrimaryViewer(ParseViewerType(GetJsonString(body)));
      }
      else if (property == "secondary-viewers")
      {
        std::set<std::string> items;
        Orthanc::SerializationToolbox::ReadSetOfStrings(items, body);

        std::set<ViewerType> viewers;
        for (std::set<std::string>::const_iterator it = items.begin(); it != items.end(); ++it)
        {
          viewers.insert(ParseViewerType(*it));
        }

        project->SetSecondaryViewers(viewers);
      }
      else if (property == "instructors")
      {
        std::set<std::string> items;
        Orthanc::SerializationToolbox::ReadSetOfStrings(items, body);
        project->SetInstructors(items);
      }
      else if (property == "learners")
      {
        std::set<std::string> items;
        Orthanc::SerializationToolbox::ReadSetOfStrings(items, body);
        project->SetLearners(items);
      }
      else if (property == "lti-context-id")
      {
        project->SetLtiContextId(GetJsonString(body));
      }
      else
      {
        throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
      }
    }
  }
  else if (request->method == OrthancPluginHttpMethod_Delete)
  {
    if (property == "lti-context-id")
    {
      project->ClearLtiContextId();
    }
    else
    {
      throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
    }
  }
  else
  {
    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "PUT,DELETE");
    return;
  }

  ProjectPermissionContext::GetProjects().Store(key, project.release());

  HttpToolbox::AnswerText(output, "");
}



namespace
{
  class Thumbnail : public boost::noncopyable
  {
  private:
    const OrthancPlugins::OrthancImage&      source_;
    std::unique_ptr<Orthanc::ImageAccessor>  modified_;

  public:
    explicit Thumbnail(const OrthancPlugins::OrthancImage& source) :
      source_(source)
    {
    }

    Orthanc::PixelFormat GetFormat() const
    {
      if (modified_.get() == NULL)
      {
        switch (source_.GetPixelFormat())
        {
          case OrthancPluginPixelFormat_Grayscale8:
            return Orthanc::PixelFormat_Grayscale8;

          case OrthancPluginPixelFormat_RGB24:
            return Orthanc::PixelFormat_RGB24;

          default:
            throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
        }
      }
      else
      {
        return modified_->GetFormat();
      }
    }

    unsigned int GetWidth() const
    {
      if (modified_.get() == NULL)
      {
        return source_.GetWidth();
      }
      else
      {
        return modified_->GetWidth();
      }
    }

    unsigned int GetHeight() const
    {
      if (modified_.get() == NULL)
      {
        return source_.GetHeight();
      }
      else
      {
        return modified_->GetHeight();
      }
    }

    void GetAccessor(Orthanc::ImageAccessor& accessor) const
    {
      if (modified_.get() == NULL)
      {
        accessor.AssignReadOnly(GetFormat(), source_.GetWidth(), source_.GetHeight(), source_.GetPitch(), source_.GetBuffer());
      }
      else
      {
        modified_->GetReadOnlyAccessor(accessor);
      }
    }

    void Resize(unsigned int width,
                unsigned int height,
                bool smooth)
    {
      Orthanc::ImageAccessor current;
      GetAccessor(current);

      std::unique_ptr<Orthanc::ImageAccessor> resized(new Orthanc::Image(current.GetFormat(), width, height, false));

      if (smooth &&
          width < current.GetWidth() &&
          height < current.GetHeight())  // Only smooth if downscaling
      {
        if (modified_.get() == NULL)
        {
          std::unique_ptr<Orthanc::ImageAccessor> smoothed(Orthanc::Image::Clone(current));
          Orthanc::ImageProcessing::SmoothGaussian5x5(*smoothed, false);
          Orthanc::ImageProcessing::Resize(*resized, *smoothed);
        }
        else
        {
          // The smoothing can be done inplace, as "resized" will overwrite "modified_"
          Orthanc::ImageProcessing::SmoothGaussian5x5(*modified_, false);
          Orthanc::ImageProcessing::Resize(*resized, *modified_);
        }
      }
      else
      {
        Orthanc::ImageProcessing::Resize(*resized, current);
      }

      modified_.reset(resized.release());
    }
  };
}



static const unsigned int THUMBNAIL_WIDTH = 128;
static const unsigned int THUMBNAIL_HEIGHT = 128;


/**
 * NB: "OrthancPlugins::OrthancImage" is used instead of "Orthanc::Image"
 * to avoid linking the education plugin against libjpeg
 **/
static void ResizeThumbnail(OrthancPlugins::OrthancImage& target,
                            const OrthancPlugins::OrthancImage& source)
{
  assert(target.GetWidth() == THUMBNAIL_WIDTH);
  assert(target.GetHeight() == THUMBNAIL_HEIGHT);

  Thumbnail thumbnail(source);

  while (thumbnail.GetWidth() / 2 > target.GetWidth() ||
         thumbnail.GetHeight() / 2 > target.GetHeight())
  {
    // Smooth once we reach the end of the successive resizings
    const bool smooth = (thumbnail.GetWidth() <= 4 * target.GetWidth() &&
                         thumbnail.GetHeight() <= 4 * target.GetHeight());
    thumbnail.Resize(thumbnail.GetWidth() / 2, thumbnail.GetHeight() / 2, smooth);
  }

  const float ratio = std::min(static_cast<float>(target.GetWidth()) / static_cast<float>(thumbnail.GetWidth()),
                               static_cast<float>(target.GetHeight()) / static_cast<float>(thumbnail.GetHeight()));
  thumbnail.Resize(static_cast<unsigned int>(Orthanc::Math::llround(static_cast<float>(thumbnail.GetWidth()) * ratio)),
                   static_cast<unsigned int>(Orthanc::Math::llround(static_cast<float>(thumbnail.GetHeight()) * ratio)),
                   true /* smooth by default */);

  unsigned int offsetX = (target.GetWidth() - thumbnail.GetWidth()) / 2;
  unsigned int offsetY = (target.GetHeight() - thumbnail.GetHeight()) / 2;

  Orthanc::ImageAccessor targetAccessor;
  targetAccessor.AssignWritable(Orthanc::PixelFormat_RGB24, target.GetWidth(), target.GetHeight(), target.GetPitch(), target.GetBuffer());

  Orthanc::ImageAccessor region;
  targetAccessor.GetRegion(region, offsetX, offsetY, thumbnail.GetWidth(), thumbnail.GetHeight());

  Orthanc::ImageAccessor thumbnailAccessor;
  thumbnail.GetAccessor(thumbnailAccessor);
  Orthanc::ImageProcessing::Convert(region, thumbnailAccessor);
}


template <Orthanc::ResourceType level>
void GeneratePreview(OrthancPluginRestOutput* output,
                     const std::string& url,
                     const OrthancPluginHttpRequest* request,
                     const AuthenticatedUser& user)
{
  const std::string resourceId(request->groups[0]);

  {
    ProjectPermissionContext::Granter granter(user);

    if (!OrthancDatabase::IsGrantedResource(granter, level, resourceId))
    {
      throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess);
    }
  }

  std::string resourcePath;
  std::string lastUpdatePath;

  switch (level)
  {
    case Orthanc::ResourceType_Study:
      resourcePath = "/studies/" + resourceId;
      lastUpdatePath = resourcePath + "/metadata/LastUpdate";
      break;

    case Orthanc::ResourceType_Series:
      resourcePath = "/series/" + resourceId;
      lastUpdatePath = resourcePath + "/metadata/LastUpdate";
      break;

    case Orthanc::ResourceType_Instance:
      resourcePath = "/instances/" + resourceId;
      lastUpdatePath = resourcePath + "/metadata/ReceptionDate";
      break;

    default:
      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
  }

  std::string preview;

  std::string lastUpdate;
  if (OrthancPlugins::RestApiGetString(lastUpdate, lastUpdatePath, false))
  {
    Json::Value metadata;
    if (OrthancPlugins::RestApiGet(metadata, resourcePath + "/metadata/" + METADATA_PREVIEW, false))
    {
      if (lastUpdate == Orthanc::SerializationToolbox::ReadString(metadata, "last-update", ""))
      {
        std::string b64 = Orthanc::SerializationToolbox::ReadString(metadata, "jpeg", "");
        if (!b64.empty())
        {
          Orthanc::Toolbox::DecodeBase64(preview, b64);
          HttpToolbox::AnswerBuffer(output, preview, Orthanc::MimeType_Jpeg);
          return;
        }
      }
    }
  }

  {
    static Orthanc::Semaphore previewThrottler_(4);
    Orthanc::Semaphore::Locker locker(previewThrottler_);

    OrthancPlugins::HttpHeaders headers;
    headers["Accept"] = Orthanc::EnumerationToString(Orthanc::MimeType_Jpeg);

    bool success = false;

    switch (level)
    {
      case Orthanc::ResourceType_Study:
      {
        Json::Value study, series;
        if (OrthancPlugins::RestApiGet(study, resourcePath, false) &&
            OrthancPlugins::RestApiGet(series, "/series/" + study["Series"][0].asString(), false))
        {
          const std::string instance = series["Instances"][0].asString();
          success = OrthancPlugins::RestApiGetString(preview, "/instances/" + instance + "/preview", headers, false);
        }

        break;
      }

      case Orthanc::ResourceType_Series:
      {
        Json::Value series;
        if (OrthancPlugins::RestApiGet(series, resourcePath, false))
        {
          const std::string instance = series["Instances"][0].asString();

          std::string sopClassUid;
          if (EducationConfiguration::GetInstance().HasPluginWholeSlideImaging() &&
              OrthancPlugins::RestApiGetString(sopClassUid, "/instances/" + instance + "/metadata/SopClassUid", false) &&
              Orthanc::Toolbox::StripSpaces(sopClassUid) == "1.2.840.10008.5.1.4.1.1.77.1.6")
          {
            // This is a microscopy image
            static const char* const FIELD_RESOLUTIONS = "Resolutions";

            Json::Value pyramid;
            if (OrthancPlugins::RestApiGet(pyramid, "/wsi/pyramids/" + resourceId, headers, true) &&
                pyramid.isMember(FIELD_RESOLUTIONS))
            {
              const Json::Value& resolutions = pyramid[FIELD_RESOLUTIONS];
              if (resolutions.type() == Json::arrayValue)
              {
                size_t largestIndex = 0;
                double largestValue = resolutions[0].asDouble();
                for (Json::Value::ArrayIndex i = 1; i < resolutions.size(); i++)
                {
                  double value = resolutions[i].asDouble();
                  if (value > largestValue)
                  {
                    largestIndex = i;
                    largestValue = value;
                  }
                }

                success = OrthancPlugins::RestApiGetString(preview, "/wsi/tiles/" + resourceId + "/" +
                                                           boost::lexical_cast<std::string>(largestIndex) + "/0/0", headers, true);
              }
            }
          }

          if (!success)
          {
            success = OrthancPlugins::RestApiGetString(preview, "/instances/" + instance + "/preview", headers, false);
          }
        }

        break;
      }

      case Orthanc::ResourceType_Instance:
        success = OrthancPlugins::RestApiGetString(preview, resourcePath + "/preview", headers, false);
        break;

      default:
        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
    }

    OrthancPlugins::OrthancImage thumbnail(OrthancPluginPixelFormat_RGB24, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
    memset(thumbnail.GetBuffer(), 255, thumbnail.GetPitch() * thumbnail.GetHeight());

    if (success)
    {
      OrthancPlugins::OrthancImage decoded;
      decoded.UncompressJpegImage(preview.empty() ? NULL : preview.c_str(), preview.size());
      ResizeThumbnail(thumbnail, decoded);
    }

    OrthancPlugins::MemoryBuffer jpeg;
    thumbnail.CompressJpegImage(jpeg, 70);
    jpeg.ToString(preview);
  }

  std::string b64;
  Orthanc::Toolbox::EncodeBase64(b64, preview);

  Json::Value metadata = Json::objectValue;
  metadata["jpeg"] = b64;
  metadata["last-update"] = lastUpdate;

  Json::Value dummy;
  OrthancPlugins::RestApiPut(dummy, resourcePath + "/metadata/" + METADATA_PREVIEW, metadata.toStyledString(), false);

  HttpToolbox::AnswerBuffer(output, preview, Orthanc::MimeType_Jpeg);
}


static void GetResourceFromBody(Orthanc::ResourceType& level /* out */,
                                std::string& resourceId /* out */,
                                const Json::Value& body)
{
  Json::Value resource;

  if (HttpToolbox::LookupJsonObject(resource, body, "resource"))
  {
    level = Orthanc::StringToResourceType(Orthanc::SerializationToolbox::ReadString(resource, "level").c_str());
    resourceId = Orthanc::SerializationToolbox::ReadString(resource, "resource-id");
  }
  else
  {
    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
  }
}


void ChangeImageTitle(OrthancPluginRestOutput* output,
                      const std::string& url,
                      const OrthancPluginHttpRequest* request,
                      const AuthenticatedUser& user,
                      const Json::Value& body)
{
  assert(user.GetRole() == Role_Administrator);

  Orthanc::ResourceType level;
  std::string resourceId;
  GetResourceFromBody(level, resourceId, body);

  const std::string title = Orthanc::SerializationToolbox::ReadString(body, "title");

  std::string path;
  switch (level)
  {
    case Orthanc::ResourceType_Study:
      path = "/studies/";
      break;

    case Orthanc::ResourceType_Series:
      path = "/series/";
      break;

    case Orthanc::ResourceType_Instance:
      path = "/instances/";
      break;

    default:
      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
  }

  path += resourceId + "/metadata/" + METADATA_INFO;

  Json::Value metadata;
  if (!OrthancPlugins::RestApiGet(metadata, path, false) ||
      metadata.type() != Json::objectValue)
  {
    // The metadata has not been created yet
    metadata = Json::objectValue;
  }

  metadata["title"] = title;

  Json::Value dummy;
  if (OrthancPlugins::RestApiPut(metadata, path, metadata, false))
  {
    metadata = Json::objectValue;
    HttpToolbox::AnswerText(output, "");
  }
  else
  {
    throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
  }
}


void LinkResourceWithProject(OrthancPluginRestOutput* output,
                             const std::string& url,
                             const OrthancPluginHttpRequest* request,
                             const AuthenticatedUser& user,
                             const Json::Value& body)
{
  assert(user.GetRole() == Role_Administrator);

  const std::string label = LABEL_PREFIX + Orthanc::SerializationToolbox::ReadString(body, "project");

  Orthanc::ResourceType level;
  std::string resourceId;

  Json::Value resource;
  if (HttpToolbox::LookupJsonObject(resource, body, "resource"))
  {
    resourceId = Orthanc::SerializationToolbox::ReadString(resource, "resource-id");
    level = Orthanc::StringToResourceType(Orthanc::SerializationToolbox::ReadString(resource, "level").c_str());
  }
  else
  {
    const std::string data = Orthanc::SerializationToolbox::ReadString(body, "data");

    if (!OrthancDatabase::LookupResourceByUserInput(level, resourceId, data))
    {
      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest, "Cannot find DICOM corresponding to: " + data);
    }
  }

  std::string path;
  switch (level)
  {
    case Orthanc::ResourceType_Study:
      path = "/studies/" + resourceId;
      break;

    case Orthanc::ResourceType_Series:
      path = "/series/" + resourceId;
      break;

    case Orthanc::ResourceType_Instance:
      path = "/instances/" + resourceId;
      break;

    default:
      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
  }

  Json::Value dummy;
  if (OrthancPlugins::RestApiPut(dummy, path + "/labels/" + label, std::string(), false))
  {
    HttpToolbox::AnswerText(output, "");
  }
  else
  {
    throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
  }
}


static void CallListUnusedResources(Json::Value& answer,
                                    Orthanc::ResourceType level)
{
  std::set<std::string> allProjectIds;

  {
    DocumentOrientedDatabase::Iterator iterator(ProjectPermissionContext::GetProjects());
    while (iterator.Next())
    {
      allProjectIds.insert(iterator.GetKey());
    }
  }

  switch (level)
  {
    case Orthanc::ResourceType_Study:
      OrthancDatabase::ListUnusedStudies(answer, allProjectIds);
      break;

    case Orthanc::ResourceType_Series:
      OrthancDatabase::ListUnusedSeries(answer, allProjectIds);
      break;

    default:
      throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented);
  }
}


void ListImages(OrthancPluginRestOutput* output,
                const std::string& url,
                const OrthancPluginHttpRequest* request,
                const AuthenticatedUser& user,
                const Json::Value& body)
{
  assert(user.GetRole() == Role_Administrator);

  const std::string project = Orthanc::SerializationToolbox::ReadString(body, "project");

  Json::Value answer;

  if (project == "_all-studies")
  {
    OrthancDatabase::ListAllStudies(answer);
  }
  else if (project == "_unused-studies")
  {
    CallListUnusedResources(answer, Orthanc::ResourceType_Study);
  }
  else if (project == "_unused-series")
  {
    CallListUnusedResources(answer, Orthanc::ResourceType_Series);
  }
  else
  {
    OrthancDatabase::FindResourcesInProject(answer, project);
  }

  HttpToolbox::AnswerJson(output, answer);
}


void UnlinkResourceFromProject(OrthancPluginRestOutput* output,
                               const std::string& url,
                               const OrthancPluginHttpRequest* request,
                               const AuthenticatedUser& user,
                               const Json::Value& body)
{
  assert(user.GetRole() == Role_Administrator);

  Orthanc::ResourceType level;
  std::string resourceId;
  GetResourceFromBody(level, resourceId, body);

  const std::string projectId = Orthanc::SerializationToolbox::ReadString(body, "project");
  const std::string label = LABEL_PREFIX + projectId;

  std::string path;
  switch (level)
  {
    case Orthanc::ResourceType_Study:
      path = "/studies/" + resourceId + "/labels/" + label;
      break;

    case Orthanc::ResourceType_Series:
      path = "/series/" + resourceId + "/labels/" + label;
      break;

    case Orthanc::ResourceType_Instance:
      path = "/instances/" + resourceId + "/labels/" + label;
      break;

    default:
      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
  }

  if (OrthancPlugins::RestApiDelete(path, false))
  {
    HttpToolbox::AnswerText(output, "");
  }
  else
  {
    throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
  }
}


static void FormatSingleProjectConfiguration(Json::Value& item,
                                             const std::string& projectId,
                                             const Project& project)
{
  item["id"] = projectId;
  item["name"] = project.GetName();
  item["description"] = project.GetDescription();
  item["policy"] = EnumerationToString(project.GetPolicy());
  item["primary_viewer"] = EnumerationToString(project.GetPrimaryViewer());
  HttpToolbox::FormatViewers(item["secondary_viewers"], project.GetSecondaryViewers());
  HttpToolbox::CopySetOfStrings(item["instructors"], project.GetInstructors());
  HttpToolbox::CopySetOfStrings(item["learners"], project.GetLearners());

  if (project.HasLtiContextId())
  {
    item["lti_context_id"] = project.GetLtiContextId();
  }
}


void HandleProjectsConfiguration(OrthancPluginRestOutput* output,
                                 const std::string& url,
                                 const OrthancPluginHttpRequest* request,
                                 const AuthenticatedUser& user)
{
  assert(user.GetRole() == Role_Administrator);

  if (request->method == OrthancPluginHttpMethod_Get)
  {
    // List all the projects
    Json::Value projects = Json::arrayValue;

    {
      DocumentOrientedDatabase::Iterator iterator(ProjectPermissionContext::GetProjects());

      while (iterator.Next())
      {
        Json::Value item;
        FormatSingleProjectConfiguration(item, iterator.GetKey(), iterator.GetDocument<Project>());
        projects.append(item);
      }
    }

    HttpToolbox::AnswerJson(output, projects);
  }
  else if (request->method == OrthancPluginHttpMethod_Post)
  {
    // Create a project
    Json::Value body;
    if (!Orthanc::Toolbox::ReadJson(body, request->body, request->bodySize))
    {
      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
    }
    else
    {
      std::unique_ptr<Project> project(new Project);
      project->SetName(Orthanc::SerializationToolbox::ReadString(body, "name"));
      project->SetDescription(Orthanc::SerializationToolbox::ReadString(body, "description"));

      std::set<ViewerType> viewers;
      EducationConfiguration::GetInstance().ListAvailableViewers(viewers);

      if (viewers.empty())
      {
        // No viewer is installed, assume that Stone Web viewer is the default
        project->SetPrimaryViewer(ViewerType_StoneWebViewer);
      }
      else
      {
        project->SetSecondaryViewers(viewers);

        if (viewers.find(ViewerType_StoneWebViewer) != viewers.end())
        {
          // Use Stone Web viewer as the default, as long as it is installed
          project->SetPrimaryViewer(ViewerType_StoneWebViewer);
        }
        else
        {
          // Stone is not installed, chose a random viewer
          project->SetPrimaryViewer(*viewers.begin());
        }
      }

      Json::Value value;
      project->Serialize(value);

      const std::string key = EducationConfiguration::GetInstance().GenerateProjectId();
      ProjectPermissionContext::GetProjects().Store(key, project.release());

      Json::Value answer;
      answer["id"] = key;
      HttpToolbox::AnswerJson(output, answer);
    }
  }
  else
  {
    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "GET,POST");
  }
}


void HandleSingleProject(OrthancPluginRestOutput* output,
                         const std::string& url,
                         const OrthancPluginHttpRequest* request,
                         const AuthenticatedUser& user)
{
  assert(user.GetRole() == Role_Administrator);

  const std::string projectId(request->groups[0]);

  if (request->method == OrthancPluginHttpMethod_Get)
  {
    DocumentOrientedDatabase::Reader reader(ProjectPermissionContext::GetProjects());

    const Project* project = reader.LookupDocument<Project>(projectId);
    if (project == NULL)
    {
      throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
    }
    else
    {
      Json::Value answer;
      FormatSingleProjectConfiguration(answer, projectId, *project);
      HttpToolbox::AnswerJson(output, answer);
    }
  }
  else if (request->method == OrthancPluginHttpMethod_Delete)
  {
    ProjectPermissionContext::GetProjects().Remove(projectId);
    HttpToolbox::AnswerText(output, "");
  }
  else
  {
    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "GET,DELETE");
  }
}


void SetLtiClientId(OrthancPluginRestOutput* output,
                    const std::string& url,
                    const OrthancPluginHttpRequest* request,
                    const AuthenticatedUser& user)
{
  assert(user.GetRole() == Role_Administrator);

  if (request->method != OrthancPluginHttpMethod_Put)
  {
    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "PUT");
  }
  else
  {
    Json::Value body;
    if (!Orthanc::Toolbox::ReadJson(body, request->body, request->bodySize) ||
        body.type() != Json::stringValue)
    {
      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
    }
    else
    {
      EducationConfiguration::GetInstance().SetLtiClientId(body.asString());
      HttpToolbox::AnswerText(output, "");
    }
  }
}


void UploadFile(OrthancPluginRestOutput* output,
                const std::string& url,
                const OrthancPluginHttpRequest* request,
                const AuthenticatedUser& user)
{
  assert(user.GetRole() == Role_Administrator);

  if (request->method != OrthancPluginHttpMethod_Post)
  {
    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "POST");
  }
  else
  {
    std::map<std::string, std::string> headers;
    HttpToolbox::ConvertDictionaryFromC(headers, true, request->headersCount, request->headersKeys, request->headersValues);

    std::string uploadId, range;
    if (HttpToolbox::LookupHttpHeader(uploadId, headers, "upload-id") &&
        HttpToolbox::LookupHttpHeader(range, headers, "content-range"))
    {
      boost::regex pattern("bytes ([0-9]+)-([0-9]+)/([0-9]+)");
      boost::smatch what;

      uint64_t start, end, fileSize;
      if (!regex_match(range, what, pattern) ||
          !Orthanc::SerializationToolbox::ParseUnsignedInteger64(start, what[1]) ||
          !Orthanc::SerializationToolbox::ParseUnsignedInteger64(end, what[2]) ||
          !Orthanc::SerializationToolbox::ParseUnsignedInteger64(fileSize, what[3]))
      {
        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest);
      }
      else
      {
        ActiveUploads::GetInstance().AppendChunk(uploadId, start, end, fileSize, request->body, request->bodySize);
        HttpToolbox::AnswerText(output, "");
      }
    }
    else
    {
      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest);
    }
  }
}


void StartDicomization(OrthancPluginRestOutput* output,
                       const std::string& url,
                       const OrthancPluginHttpRequest* request,
                       const AuthenticatedUser& user)
{
  assert(user.GetRole() == Role_Administrator);

  if (request->method == OrthancPluginHttpMethod_Get)
  {
    std::set<std::string> jobs;
    DicomizationJob::GetJobsEngine().GetRegistry().ListJobs(jobs);

    Json::Value answer = Json::arrayValue;

    for (std::set<std::string>::const_iterator it = jobs.begin(); it != jobs.end(); ++it)
    {
      Orthanc::JobInfo info;
      if (DicomizationJob::GetJobsEngine().GetRegistry().GetJobInfo(info, *it))
      {
        Json::Value item;
        item["id"] = *it;
        item["time"] = boost::posix_time::to_iso_extended_string(info.GetCreationTime());
        item["name"] = Orthanc::SerializationToolbox::ReadString(info.GetStatus().GetPublicContent(), "name");
        item["type"] = info.GetStatus().GetJobType();

        switch (info.GetState())
        {
          case Orthanc::JobState_Success:
            item["status"] = "success";
            break;

          case Orthanc::JobState_Pending:
            item["status"] = "pending";
            break;

          case Orthanc::JobState_Running:
            item["status"] = "running";
            break;

          default:
            item["status"] = "failure";
            break;
        }

        answer.append(item);
      }
    }

    HttpToolbox::AnswerJson(output, answer);
  }
  else if (request->method == OrthancPluginHttpMethod_Post)
  {
    Json::Value body;
    if (!Orthanc::Toolbox::ReadJson(body, request->body, request->bodySize))
    {
      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
    }
    else
    {
      const std::string uploadId = Orthanc::SerializationToolbox::ReadString(body, "upload-id");

      std::unique_ptr<WholeSlideImagingDicomizer> dicomizer;

      try
      {
        dicomizer.reset(new WholeSlideImagingDicomizer);

        const std::string color = Orthanc::SerializationToolbox::ReadString(body, "background-color");
        if (color == "black")
        {
          dicomizer->SetBackgroundColor(0, 0, 0);
        }
        else if (color == "white")
        {
          dicomizer->SetBackgroundColor(255, 255, 255);
        }
        else
        {
          throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
        }

        dicomizer->SetStudyDescription(Orthanc::SerializationToolbox::ReadString(body, "study-description"));
        dicomizer->SetForceOpenSlide(Orthanc::SerializationToolbox::ReadBoolean(body, "force-openslide"));
        dicomizer->SetReconstructPyramid(Orthanc::SerializationToolbox::ReadBoolean(body, "reconstruct-pyramid"));

        static const char* const IMAGED_WIDTH = "imaged-width";
        if (body.isMember(IMAGED_WIDTH))
        {
          const Json::Value& width = body[IMAGED_WIDTH];
          if (width.isNumeric())
          {
            dicomizer->SetImagedVolumeWidth(width.asFloat());
          }
          else
          {
            throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
          }
        }
      }
      catch (Orthanc::OrthancException&)
      {
        ActiveUploads::GetInstance().Erase(uploadId);
        throw;
      }

      DicomizationJob::GetJobsEngine().GetRegistry().Submit(new DicomizationJob(uploadId, dicomizer.release()), 0 /* priority */);

      HttpToolbox::AnswerText(output, "");
    }
  }
  else
  {
    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "GET,POST");
  }
}


void CancelDicomization(OrthancPluginRestOutput* output,
                        const std::string& url,
                        const OrthancPluginHttpRequest* request,
                        const AuthenticatedUser& user)
{
  assert(user.GetRole() == Role_Administrator);

  const std::string jobId(request->groups[0]);

  if (request->method == OrthancPluginHttpMethod_Delete)
  {
    DicomizationJob::GetJobsEngine().GetRegistry().Cancel(jobId);
    HttpToolbox::AnswerText(output, "");
  }
  else
  {
    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "DELETE");
  }
}


void GetDicomizationLogs(OrthancPluginRestOutput* output,
                         const std::string& url,
                         const OrthancPluginHttpRequest* request,
                         const AuthenticatedUser& user)
{
  assert(user.GetRole() == Role_Administrator);

  const std::string jobId(request->groups[0]);

  Orthanc::JobInfo info;
  if (DicomizationJob::GetJobsEngine().GetRegistry().GetJobInfo(info, jobId))
  {
    std::string logs = Orthanc::SerializationToolbox::ReadString(info.GetStatus().GetPublicContent(), "logs");
    HttpToolbox::AnswerText(output, logs);
  }
  else
  {
    throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
  }
}


void RegisterEducationRestApiRoutes()
{
  /**
   * Safe routes, accessible to any user (even if not logged in)
   **/

  RestApiRouter::RegisterPublicGetRoute< ServeWebApplication<false> >("/education/app/list-projects.html");
  RestApiRouter::RegisterPublicGetRoute< ServeWebApplication<false> >("/education/app/list-projects.js");
  RestApiRouter::RegisterPublicGetRoute< ServeWebApplication<false> >("/education/app/login.js");
  RestApiRouter::RegisterPublicGetRoute< ServeWebApplication<false> >("/education/app/toolbox.js");

  RestApiRouter::RegisterPublicPostRoute<DoLogin>("/education/do-login");
  RestApiRouter::RegisterPublicGetRoute<DoLogout>("/education/do-logout");

  // Those are safe routes, as they only generate a URL
  RestApiRouter::RegisterPublicPostRoute<GenerateListProjectUrl>("/education/api/list-project-url");
  RestApiRouter::RegisterPublicPostRoute<GenerateViewerUrlFromResource>("/education/api/resource-viewer-url");


  /**
   * Routes that necessitate user authentication (even as a guest)
   **/

  RestApiRouter::RegisterAuthenticatedGetRoute<GetUserProjects>("/education/api/user-projects");
  RestApiRouter::RegisterAuthenticatedGetRoute<RedirectRoot>("/");
  RestApiRouter::RegisterAuthenticatedGetRoute<ServeConfiguration>("/education/api/config");
  RestApiRouter::RegisterAuthenticatedGetRoute<ServeLogin>("/education/app/login.html");
  RestApiRouter::RegisterAuthenticatedRoute<ChangeProjectParameter>("/education/api/projects/{}/{}");

  RestApiRouter::RegisterAuthenticatedGetRoute< GeneratePreview<Orthanc::ResourceType_Study> >("/education/api/preview-study/{}");
  RestApiRouter::RegisterAuthenticatedGetRoute< GeneratePreview<Orthanc::ResourceType_Series> >("/education/api/preview-series/{}");
  RestApiRouter::RegisterAuthenticatedGetRoute< GeneratePreview<Orthanc::ResourceType_Instance> >("/education/api/preview-instance/{}");


  /**
   * Routes that necessitate administrator credentials
   **/

  RestApiRouter::RegisterAdministratorGetRoute< ServeWebApplication<true> >("/education/app/dashboard.html");
  RestApiRouter::RegisterAdministratorGetRoute< ServeWebApplication<true> >("/education/app/dashboard.js");

  RestApiRouter::RegisterAdministratorPostRoute<ChangeImageTitle>("/education/api/change-title");
  RestApiRouter::RegisterAdministratorPostRoute<LinkResourceWithProject>("/education/api/link");
  RestApiRouter::RegisterAdministratorPostRoute<ListImages>("/education/api/list-images");
  RestApiRouter::RegisterAdministratorPostRoute<UnlinkResourceFromProject>("/education/api/unlink");
  RestApiRouter::RegisterAdministratorRoute<HandleProjectsConfiguration>("/education/api/projects");
  RestApiRouter::RegisterAdministratorRoute<HandleSingleProject>("/education/api/projects/{}");
  RestApiRouter::RegisterAdministratorRoute<SetLtiClientId>("/education/api/config/lti-client-id");

  RestApiRouter::RegisterAdministratorRoute<UploadFile>("/education/api/upload");
  RestApiRouter::RegisterAdministratorRoute<StartDicomization>("/education/api/dicomization");
  RestApiRouter::RegisterAdministratorRoute<CancelDicomization>("/education/api/dicomization/{}");
  RestApiRouter::RegisterAdministratorGetRoute<GetDicomizationLogs>("/education/api/dicomization/{}/logs");
}


AuthenticatedUser* AuthenticateFromEducationCookie(const std::list<HttpToolbox::Cookie>& cookies)
{
  for (std::list<HttpToolbox::Cookie>::const_iterator it = cookies.begin(); it != cookies.end(); ++it)
  {
    if (it->GetKey() == COOKIE_USER_AUTH)
    {
      try
      {
        return AuthenticatedUser::FromJWT(EducationConfiguration::GetInstance().GetLtiContext(), it->GetValue());
      }
      catch (Orthanc::OrthancException&)
      {
      }
    }
  }

  return NULL;
}