Product.java

/*
 * Product
 */
package gov.usgs.earthquake.product;

import gov.usgs.earthquake.distribution.SignatureVerifier;
import gov.usgs.util.CryptoUtils;
import gov.usgs.util.XmlUtils;
import gov.usgs.util.CryptoUtils.Version;

import java.security.PublicKey;
import java.security.PrivateKey;

import java.math.BigDecimal;
import java.net.URI;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.LinkedList;
import java.util.Map;
import java.util.Objects;
import java.util.HashMap;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * One or more pieces of Content with metadata.
 *
 * <dl>
 * <dt><strong>ID</strong></dt>
 * <dd>Products each have a unique {@link ProductId}.</dd>
 *
 * <dt><strong>Versioning</strong></dt>
 * <dd>It is possible to create multiple versions of the same product, by
 * reusing the same <code>source</code>, <code>type</code>, and
 * <code>code</code>, with a different <code>updateTime</code>. <br>
 * More recent (newer) <code>updateTime</code>s <strong>supersede</strong> Less
 * recent (older) <code>updateTime</code>s.</dd>
 *
 * <dt><strong>Status</strong></dt>
 * <dd>To <strong>delete</strong> a product, create a new version (updateTime)
 * and set it's status to {@link STATUS_DELETE}. All other statuses
 * ({@link STATUS_UPDATE} by default) are considered updates, and any value can
 * be used in product-specific ways.</dd>
 *
 * <dt><strong>Properties</strong></dt>
 * <dd>Products have key/value attributes that are Strings. These can be useful
 * to convey summary information about a product, so consumers can quickly
 * decide whether to process before opening any product contents.</dd>
 *
 * <dt><strong>Links</strong></dt>
 * <dd>Similar to properties, links allow a Product to specify a
 * <code>relation</code> and one or more <code>link</code> for each relation
 * type. Links must be {@link java.net.URI}s, and may be {@link ProductId}s.
 * </dd>
 *
 * <dt><strong>Contents</strong></dt>
 * <dd>Many Products start as a directory of files, and metadata is determined
 * later. It's also possible to create products without any Contents attached,
 * if all the necessary information can be encoded using Properties or Links.
 * <br>
 * One special "empty path" content, literally at the empty-string path, is
 * handled differently; since an empty path cannot be written to a file. PDL
 * typically reads this in from standard input, or delivers this on standard
 * input to external processes.</dd>
 *
 * <dt><strong>Signature</strong></dt>
 * <dd>A product can have a digital signature, based on a digest of all product
 * contents and metadata. These are required for most purposes.
 * {@link CryptoUtils} provides utilities for working with OpenSSH keypairs.
 * </dd>
 * </dl>
 */
public class Product {

  private static final Logger LOGGER = Logger.getLogger(Product.class.getName());

  /** The status message when a product is being updated. */
  public static final String STATUS_UPDATE = "UPDATE";

  /** The status message when a product is being deleted. */
  public static final String STATUS_DELETE = "DELETE";

  /** Property for eventsource */
  public static final String EVENTSOURCE_PROPERTY = "eventsource";
  /** Property for eventsourcecode */
  public static final String EVENTSOURCECODE_PROPERTY = "eventsourcecode";
  /** Property for eventtime */
  public static final String EVENTTIME_PROPERTY = "eventtime";
  /** Property for magnitude */
  public static final String MAGNITUDE_PROPERTY = "magnitude";
  /** Property for latitude */
  public static final String LATITUDE_PROPERTY = "latitude";
  /** Property for longitude */
  public static final String LONGITUDE_PROPERTY = "longitude";
  /** Property for depth */
  public static final String DEPTH_PROPERTY = "depth";
  /** Property for version */
  public static final String VERSION_PROPERTY = "version";

  /** A unique identifier for this product. */
  private ProductId id;

  /** A terse status message. */
  private String status;

  /** Properties of this product. */
  private Map<String, String> properties = new HashMap<String, String>();

  /** Links to other products and related resources. */
  private Map<String, List<URI>> links = new HashMap<String, List<URI>>();

  /** Product contents. Mapping from path to content. */
  private Map<String, Content> contents = new HashMap<String, Content>();

  /**
   * @deprecated A signature generated by the product creator. Resides within
   *             signatureHistory.
   */
  @Deprecated
  private String signature = null;

  /** @deprecated Signature version. Resides within signatureHistory. */
  @Deprecated
  private Version signatureVersion = Version.SIGNATURE_V2;

  /** Signature history. */
  private List<ProductSignature> signatureHistory = new ArrayList<>();

  /**
   * Construct a new Product with status "UPDATE".
   *
   * @param id the product's unique Id.
   */
  public Product(final ProductId id) {
    this(id, STATUS_UPDATE);
  }

  /**
   * Construct a new Product.
   *
   * @param id     the product's unique Id.
   * @param status the product's status.
   */
  public Product(final ProductId id, final String status) {
    setId(id);
    setStatus(status);
  }

  /**
   * Copy constructor.
   *
   * @param that the product to copy.
   */
  public Product(final Product that) {
    this(new ProductId(that.getId().getSource(), that.getId().getType(), that.getId().getCode(),
        that.getId().getUpdateTime()), that.getStatus());
    this.setProperties(that.getProperties());
    this.setLinks(that.getLinks());
    this.setContents(that.getContents());
    this.setSignature(that.getSignature());
    this.setSignatureHistory(that.getSignatureHistory());
  }

  /**
   * @return the id
   */
  public ProductId getId() {
    return id;
  }

  /**
   * @param id the id to set
   */
  public void setId(final ProductId id) {
    this.id = id;
  }

  /**
   * @return the status
   */
  public String getStatus() {
    return status;
  }

  /**
   * @param status the status to set
   */
  public void setStatus(final String status) {
    this.status = status;
  }

  /**
   * Product.STATUS_DELETE.equalsIgnoreCase(status).
   *
   * @return whether this product is deleted
   */
  public boolean isDeleted() {
    if (STATUS_DELETE.equalsIgnoreCase(this.status)) {
      return true;
    } else {
      return false;
    }
  }

  /**
   * @return the properties
   */
  public Map<String, String> getProperties() {
    return properties;
  }

  /**
   * @param properties the properties to set
   */
  public void setProperties(final Map<String, String> properties) {
    this.properties.putAll(properties);
  }

  /**
   * Returns a reference to the links map.
   *
   * @return the links
   */
  public Map<String, List<URI>> getLinks() {
    return links;
  }

  /**
   * Copies entries from provided map.
   *
   * @param links the links to set
   */
  public void setLinks(final Map<String, List<URI>> links) {
    this.links.putAll(links);
  }

  /**
   * Add a link to a product.
   *
   * @param relation how link is related to product.
   * @param href     actual link.
   */
  public void addLink(final String relation, final URI href) {
    List<URI> relationLinks = links.get(relation);
    if (relationLinks == null) {
      relationLinks = new LinkedList<URI>();
      links.put(relation, relationLinks);
    }
    relationLinks.add(href);
  }

  /**
   * Returns a reference to the contents map.
   *
   * @return the contents
   */
  public Map<String, Content> getContents() {
    return contents;
  }

  /**
   * Copies entries from provided map.
   *
   * @param contents the contents to set
   */
  public void setContents(final Map<String, Content> contents) {
    this.contents.clear();
    this.contents.putAll(contents);
  }

  /**
   * @return the signature
   */
  public String getSignature() {
    return signature;
  }

  /**
   * @return the signature
   */
  public Version getSignatureVersion() {
    return signatureVersion;
  }

  /**
   * 
   * @return the signature history
   */
  public List<ProductSignature> getSignatureHistory() {
    return signatureHistory;
  }

  /**
   * @param signature the signature to set
   */
  public void setSignature(final String signature) {
    this.signature = signature;
  }

  /**
   * @param version the signature version to set
   */
  public void setSignatureVersion(final Version version) {
    this.signatureVersion = version;
  }

  /**
   * 
   * @param historyList the signature history to set
   */
  public void setSignatureHistory(final List<ProductSignature> historyList) {
    this.signatureHistory = historyList;
  }

  /**
   * @param entry the map entry to prepend to signature history
   * 
   */
  public void addToSignatureHistory(ProductSignature entry) {
    if (!Objects.isNull(entry.getSignature()) && !Objects.isNull(entry.getSignatureVersion()))
      // check if history already contains signature before adding to history
      if (!signatureHistory.contains(entry)) {
        this.signatureHistory.add(0, entry);
      } else {
        LOGGER.log(Level.FINEST, "Signature is already in history.");
      }
    else {
      LOGGER.log(Level.FINEST, "Signature is null.");
    }

  }

  /**
   * Sign this product using a PrivateKey.
   *
   * @param privateKey a DSAPrivateKey or RSAPrivateKey.
   * @param version    the signature version to use.
   * @throws Exception if error occurs
   */
  public void sign(final PrivateKey privateKey, final Version version) throws Exception {
    setSignature(CryptoUtils.sign(privateKey, ProductDigest.digestProduct(this, version), version));
    setSignatureVersion(version);

    addToSignatureHistory(new ProductSignature(getSignature(), getSignatureVersion()));
  }

  /**
   * Verify this product's signature using Signature V1.
   *
   * @param publicKeys Array of public keys to verify
   * @throws Exception if error occurs
   * @return true if valid, false otherwise.
   */
  public boolean verifySignature(final PublicKey[] publicKeys) throws Exception {
    return verifySignature(publicKeys, getSignatureVersion(), SignatureVerifier.HISTORY_DEPTH_DEFAULT);
  }

  /**
   * Verify this product's signature.
   *
   * When a product has no signature, this method returns false. The array of
   * public keys corresponds to one or more keys that may have generated the
   * signature. If any of the keys verify, this method returns true.
   *
   * @param publicKeys   an array of publicKeys to test.
   * @param version      the signature version to use.
   * @param historyDepth how far to look into the signatureHistory
   * @return true if valid, false otherwise.
   * @throws Exception if error occurs
   */
  public boolean verifySignature(final PublicKey[] publicKeys, final Version version,
      int historyDepth) throws Exception {
    if (signatureHistory.size() == 0) {
      return verifySignatureKey(publicKeys, version) != null;
    }
    return verifySignatureKey(publicKeys, version, historyDepth) != null;
  }

  /**
   * Try to verify using multiple candidate keys.
   *
   * @param publicKeys   an array of publicKeys to test
   * @param version      the signature version to use.
   * @param historyDepth how far to look into the signatureHistory
   * @return true if valid, false otherwise.
   * @throws Exception if error occurs
   */
  public PublicKey verifySignatureKey(final PublicKey[] publicKeys, final Version version, int historyDepth)
      throws Exception {
    if (signatureHistory == null) {
      return null;
    }

    byte[] digest = ProductDigest.digestProduct(this, version);

    historyDepth = Math.min(historyDepth, signatureHistory.size());

    LOGGER.log(Level.FINEST, "Verifying " + historyDepth + " signature(s) in signature history");
    for (int i = 0; i < historyDepth; i++) {
      String signature = signatureHistory.get(i).getSignature();
      for (PublicKey key : publicKeys) {
        try {
          if (CryptoUtils.verify(key, digest, signature, version)) {
            return key;
          }
        } catch (Exception e) {
          LOGGER.log(Level.FINEST, "Exception while verifying signature", e);
        }
      }
    }
    return null;
  }

  /**
   * Try to verify using multiple candidate keys.
   *
   * @param publicKeys an array of publicKeys to test
   * @param version    the signature version to use.
   * @return true if valid, false otherwise.
   * @throws Exception if error occurs
   */
  @Deprecated
  public PublicKey verifySignatureKey(final PublicKey[] publicKeys, final Version version) throws Exception {
    if (signature == null) {
      return null;
    }

    byte[] digest = ProductDigest.digestProduct(this, version);
    for (PublicKey key : publicKeys) {
      try {
        if (CryptoUtils.verify(key, digest, getSignature(), version)) {
          return key;
        }
      } catch (Exception e) {
        LOGGER.log(Level.FINEST, "Exception while verifying signature", e);
      }
    }
    return null;
  }

  /**
   * Get the event id.
   *
   * The event id is the combination of event source and event source code.
   *
   * @return the event id, or null if either event source or event source code is
   *         null.
   */
  public String getEventId() {
    String eventSource = getEventSource();
    String eventSourceCode = getEventSourceCode();
    if (eventSource == null && eventSourceCode == null) {
      return null;
    }
    return (eventSource + eventSourceCode).toLowerCase();
  }

  /**
   * Set both the network and networkId at the same time.
   *
   * @param source     the originating network.
   * @param sourceCode the originating network's id.
   */
  public void setEventId(final String source, final String sourceCode) {
    setEventSource(source);
    setEventSourceCode(sourceCode);
  }

  /**
   * Get the event source property.
   *
   * @return the event source property, or null if no event source property set.
   */
  public String getEventSource() {
    return this.properties.get(EVENTSOURCE_PROPERTY);
  }

  /**
   * Set the event source property.
   *
   * @param eventSource the event source to set.
   */
  public void setEventSource(final String eventSource) {
    if (eventSource == null) {
      this.properties.remove(EVENTSOURCE_PROPERTY);
    } else {
      this.properties.put(EVENTSOURCE_PROPERTY, eventSource.toLowerCase());
    }
  }

  /**
   * Get the event source code property.
   *
   * @return the event source code property, or null if no event source code
   *         property set.
   */
  public String getEventSourceCode() {
    return this.properties.get(EVENTSOURCECODE_PROPERTY);
  }

  /**
   * Set the event id property.
   *
   * @param eventSourceCode the event id to set.
   */
  public void setEventSourceCode(final String eventSourceCode) {
    if (eventSourceCode == null) {
      this.properties.remove(EVENTSOURCECODE_PROPERTY);
    } else {
      this.properties.put(EVENTSOURCECODE_PROPERTY, eventSourceCode.toLowerCase());
    }
  }

  /**
   * Get the event time property as a date.
   *
   * @return the event time property as a date, or null if no date property set.
   */
  public Date getEventTime() {
    String strDate = this.properties.get(EVENTTIME_PROPERTY);
    if (strDate == null) {
      return null;
    }
    return XmlUtils.getDate(strDate);
  }

  /**
   * Set the event time property as a date.
   *
   * @param eventTime the event time to set.
   */
  public void setEventTime(final Date eventTime) {
    if (eventTime == null) {
      this.properties.remove(EVENTTIME_PROPERTY);
    } else {
      this.properties.put(EVENTTIME_PROPERTY, XmlUtils.formatDate(eventTime));
    }
  }

  /**
   * Get the magnitude property as a big decimal.
   *
   * @return the magnitude property as a big decimal, or null if no magnitude
   *         property set.
   */
  public BigDecimal getMagnitude() {
    String strMag = this.properties.get(MAGNITUDE_PROPERTY);
    if (strMag == null) {
      return null;
    }
    return new BigDecimal(strMag);
  }

  /**
   * Set the magnitude property as a big decimal.
   *
   * @param magnitude the magnitude to set.
   */
  public void setMagnitude(final BigDecimal magnitude) {
    if (magnitude == null) {
      this.properties.remove(MAGNITUDE_PROPERTY);
    } else {
      this.properties.put(MAGNITUDE_PROPERTY, magnitude.toPlainString());
    }
  }

  /**
   * Get the latitude property as a big decimal.
   *
   * @return latitude property as a big decimal, or null if no latitude property
   *         set.
   */
  public BigDecimal getLatitude() {
    String strLat = this.properties.get(LATITUDE_PROPERTY);
    if (strLat == null) {
      return null;
    }
    return new BigDecimal(strLat);
  }

  /**
   * Set the latitude property as a big decimal.
   *
   * @param latitude the latitude to set.
   */
  public void setLatitude(final BigDecimal latitude) {
    if (latitude == null) {
      this.properties.remove(LATITUDE_PROPERTY);
    } else {
      this.properties.put(LATITUDE_PROPERTY, latitude.toPlainString());
    }
  }

  /**
   * Get the longitude property as a big decimal.
   *
   * @return longitude property as a big decimal, or null if no longitude property
   *         set.
   */
  public BigDecimal getLongitude() {
    String strLon = this.properties.get(LONGITUDE_PROPERTY);
    if (strLon == null) {
      return null;
    }
    return new BigDecimal(strLon);
  }

  /**
   * Set the longitude property as a big decimal.
   *
   * @param longitude the longitude to set.
   */
  public void setLongitude(final BigDecimal longitude) {
    if (longitude == null) {
      this.properties.remove(LONGITUDE_PROPERTY);
    } else {
      this.properties.put(LONGITUDE_PROPERTY, longitude.toPlainString());
    }
  }

  /**
   * Get the depth property as a big decimal.
   *
   * @return depth property as big decimal, or null if no depth property set.
   */
  public BigDecimal getDepth() {
    String strDepth = this.properties.get(DEPTH_PROPERTY);
    if (strDepth == null) {
      return null;
    }
    return new BigDecimal(strDepth);
  }

  /**
   * Set the depth property as a big decimal.
   *
   * @param depth the depth to set.
   */
  public void setDepth(final BigDecimal depth) {
    if (depth == null) {
      this.properties.remove(DEPTH_PROPERTY);
    } else {
      this.properties.put(DEPTH_PROPERTY, depth.toPlainString());
    }
  }

  /**
   * Get the version property.
   *
   * @return the version property, or null if no version property set.
   */
  public String getVersion() {
    return this.properties.get(VERSION_PROPERTY);
  }

  /**
   * Set the version property.
   *
   * @param version the version to set.
   */
  public void setVersion(final String version) {
    if (version == null) {
      this.properties.remove(VERSION_PROPERTY);
    } else {
      this.properties.put(VERSION_PROPERTY, version);
    }
  }

}