Watermark.java

package org.silverpeas.components.gallery;

import org.silverpeas.core.util.file.FileRepositoryManager;
import org.silverpeas.kernel.logging.SilverLogger;

import org.apache.commons.io.input.BoundedInputStream;

import javax.ws.rs.core.MediaType;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;

import static java.net.http.HttpResponse.BodyHandlers.ofInputStream;
import static org.apache.commons.io.FilenameUtils.*;
import static org.silverpeas.core.util.HttpUtil.httpClientBuilder;
import static org.silverpeas.core.util.HttpUtil.toUrl;
import static org.silverpeas.kernel.util.StringUtil.isDefined;

public class Watermark {

  private static final Duration FETCH_TIMEOUT = Duration.ofSeconds(10);
  private static final long MAX_IMAGE_SIZE = 10L * 1024 * 1024;

  private boolean enabled = false;

  private String propertyIPTCForHD;

  private String propertyIPTCForThumbnails;

  private String textForHD;

  private String textForThumbnails;

  private File imageForHD;

  private File imageForThumbnails;

  boolean isEnabled() {
    return enabled;
  }

  void setEnabled(final boolean enabled) {
    this.enabled = enabled;
  }

  String getIPTCPropertyForHD() {
    return propertyIPTCForHD;
  }

  void setIPTCPropertyForHD(final String IPTCPropertyForHD) {
    this.propertyIPTCForHD = IPTCPropertyForHD;
  }

  String getIPTCPropertyForThumbnails() {
    return propertyIPTCForThumbnails;
  }

  void setIPTCPropertyForThumbnails(final String IPTCPropertyForThumbnails) {
    this.propertyIPTCForThumbnails = IPTCPropertyForThumbnails;
  }

  String getTextForHD() {
    return textForHD;
  }

  void setTextForHD(final String textForHD) {
    this.textForHD = textForHD;
  }

  String getTextForThumbnails() {
    return textForThumbnails;
  }

  void setTextForThumbnails(final String textForThumbnails) {
    this.textForThumbnails = textForThumbnails;
  }

  File getImageForHD() {
    return imageForHD;
  }

  synchronized void setImageUrlForHD(String componentInstanceId, final String imageUrlForHD) {
    this.imageForHD = processImageUrl(componentInstanceId, imageUrlForHD);
  }

  File getImageForThumbnails() {
    return imageForThumbnails;
  }

  synchronized void setImageUrlForThumbnails(String componentInstanceId,
      final String imageUrlForThumbnails) {
    this.imageForThumbnails = processImageUrl(componentInstanceId, imageUrlForThumbnails);
  }

  private synchronized File processImageUrl(String componentInstanceId, final String imageUrl) {
    File cachedFile = null;
    if (isDefined(imageUrl)) {
      final String imageUrlWithoutProtocol =
          imageUrl.replaceAll("(file|https?):/+", "").replaceAll("[&=%!;*?]", "").replaceAll(":([0-9]+)", "$1");
      final String normalizedName = normalize(imageUrlWithoutProtocol.replaceAll("[/\\\\:]", ""));
      final String extension = getExtension(normalizedName);
      final Path cachedPath = Paths.get(FileRepositoryManager.getTemporaryPath(),
          componentInstanceId + "_watermark_" + getBaseName(normalizedName) + "." +
              (isDefined(extension) ? extension : "png"));
      cachedFile = cachedPath.toFile();
      if (!cachedFile.exists()) {
        cachedFile = cacheImage(imageUrl, cachedFile, imageUrlWithoutProtocol, cachedPath);
      }
    }
    return cachedFile;
  }

  private File cacheImage(final String imageUrl, File cachedFile,
      final String imageUrlWithoutProtocol, final Path cachedPath) {
    final Path watermarkSource = Paths.get(imageUrlWithoutProtocol);
    if (watermarkSource.toFile().exists()) {
      try {
        Files.copy(watermarkSource, cachedPath);
      } catch (IOException e) {
        cachedFile = null;
        SilverLogger.getLogger(this).warn(e);
        SilverLogger.getLogger(this).warn("impossible to save image from URL {0}", imageUrl);
      }
    } else if (!isFetchable(imageUrl)) {
      cachedFile = null;
      SilverLogger.getLogger(this).warn("refused to fetch the watermark image from URL {0}",
          imageUrl);
    } else {
      try {
        final HttpClient client = httpClientBuilder()
            // a redirection would escape the verification performed by isFetchable above
            .followRedirects(HttpClient.Redirect.NEVER)
            .connectTimeout(FETCH_TIMEOUT)
            .build();
        final HttpResponse<InputStream> response = client.send(toUrl(imageUrl)
            .timeout(FETCH_TIMEOUT)
            .header("Accept", MediaType.WILDCARD)
            .build(), ofInputStream());
        try (final InputStream body = new BoundedInputStream(response.body(), MAX_IMAGE_SIZE)) {
          Files.copy(body, cachedPath);
        }
        if (Files.size(cachedPath) >= MAX_IMAGE_SIZE) {
          Files.deleteIfExists(cachedPath);
          cachedFile = null;
          SilverLogger.getLogger(this)
              .warn("watermark image from URL {0} exceeds {1} bytes", imageUrl, MAX_IMAGE_SIZE);
        }
      } catch (Exception e) {
        cachedFile = null;
        SilverLogger.getLogger(this).warn(e);
        SilverLogger.getLogger(this).warn("impossible to save image from URL {0}", imageUrl);
        if (e instanceof InterruptedException) {
          Thread.currentThread().interrupt();
        }
      }
    }
    return cachedFile;
  }

  /**
   * Can the image be fetched from the given URL? Only the HTTP and HTTPS schemes are handled, and
   * the host must resolve to neither a loopback nor a link-local address, so that a watermark URL
   * cannot be used to reach the services the server deliberately keeps for itself nor the metadata
   * endpoint of a cloud provider. The addresses of the private networks of an organization are
   * deliberately allowed: they are where the internal resources of an intranet legitimately live,
   * and no address range can tell them from the internal services one would rather protect. Only
   * an explicit list of allowed hosts could, which is left to a further decision.
   * @param imageUrl the URL set as an instance parameter.
   * @return true if the URL can be requested, false otherwise.
   */
  static boolean isFetchable(final String imageUrl) {
    try {
      final URI uri = URI.create(imageUrl);
      final String scheme = uri.getScheme();
      if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
        return false;
      }
      final String host = uri.getHost();
      if (host == null) {
        return false;
      }
      // every address the host resolves to is verified, otherwise a host resolving to a forbidden
      // address beside an allowed one would go through
      for (final InetAddress address : InetAddress.getAllByName(host)) {
        if (address.isLoopbackAddress() || address.isLinkLocalAddress() ||
            address.isAnyLocalAddress() || address.isMulticastAddress()) {
          return false;
        }
      }
      return true;
    } catch (IllegalArgumentException | UnknownHostException e) {
      SilverLogger.getLogger(Watermark.class).warn(e);
      return false;
    }
  }

  boolean isBasedOnIPTC() {
    return isDefined(getIPTCPropertyForHD()) ||
        isDefined(getIPTCPropertyForThumbnails());
  }

  boolean isDefinedForThumbnails() {
    return isDefined(getIPTCPropertyForThumbnails()) ||
        isDefined(getTextForThumbnails()) || getImageForThumbnails() != null;
  }
}