ContentPackage.java

  1. /*
  2.  * #%L
  3.  * wcm.io
  4.  * %%
  5.  * Copyright (C) 2015 wcm.io
  6.  * %%
  7.  * Licensed under the Apache License, Version 2.0 (the "License");
  8.  * you may not use this file except in compliance with the License.
  9.  * You may obtain a copy of the License at
  10.  *
  11.  *      http://www.apache.org/licenses/LICENSE-2.0
  12.  *
  13.  * Unless required by applicable law or agreed to in writing, software
  14.  * distributed under the License is distributed on an "AS IS" BASIS,
  15.  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  16.  * See the License for the specific language governing permissions and
  17.  * limitations under the License.
  18.  * #L%
  19.  */
  20. package io.wcm.tooling.commons.contentpackagebuilder;

  21. import static io.wcm.tooling.commons.contentpackagebuilder.NameUtil.ensureValidPath;
  22. import static org.apache.jackrabbit.vault.util.Constants.CONFIG_XML;
  23. import static org.apache.jackrabbit.vault.util.Constants.DOT_CONTENT_XML;
  24. import static org.apache.jackrabbit.vault.util.Constants.FILTER_XML;
  25. import static org.apache.jackrabbit.vault.util.Constants.META_DIR;
  26. import static org.apache.jackrabbit.vault.util.Constants.PACKAGE_DEFINITION_XML;
  27. import static org.apache.jackrabbit.vault.util.Constants.PROPERTIES_XML;
  28. import static org.apache.jackrabbit.vault.util.Constants.ROOT_DIR;
  29. import static org.apache.jackrabbit.vault.util.Constants.SETTINGS_XML;

  30. import java.io.Closeable;
  31. import java.io.File;
  32. import java.io.FileInputStream;
  33. import java.io.IOException;
  34. import java.io.InputStream;
  35. import java.io.OutputStream;
  36. import java.nio.charset.StandardCharsets;
  37. import java.util.HashSet;
  38. import java.util.List;
  39. import java.util.Map;
  40. import java.util.Objects;
  41. import java.util.Properties;
  42. import java.util.Set;
  43. import java.util.zip.ZipEntry;
  44. import java.util.zip.ZipOutputStream;

  45. import javax.xml.XMLConstants;
  46. import javax.xml.transform.OutputKeys;
  47. import javax.xml.transform.Transformer;
  48. import javax.xml.transform.TransformerException;
  49. import javax.xml.transform.TransformerFactory;
  50. import javax.xml.transform.dom.DOMSource;
  51. import javax.xml.transform.stream.StreamResult;

  52. import org.apache.commons.io.FilenameUtils;
  53. import org.apache.commons.io.IOUtils;
  54. import org.apache.commons.lang3.StringUtils;
  55. import org.apache.jackrabbit.vault.packaging.PackageProperties;
  56. import org.apache.jackrabbit.vault.util.PlatformNameFormat;
  57. import org.jetbrains.annotations.NotNull;
  58. import org.w3c.dom.Document;

  59. import io.wcm.tooling.commons.contentpackagebuilder.ContentFolderSplitter.ContentPart;
  60. import io.wcm.tooling.commons.contentpackagebuilder.element.ContentElement;

  61. /**
  62.  * Represents an AEM content package.
  63.  * Content like structured JCR data and binary files can be added.
  64.  * This class is not thread-safe.
  65.  */
  66. public final class ContentPackage implements Closeable {

  67.   private final PackageMetadata metadata;
  68.   private final ZipOutputStream zip;
  69.   private final Transformer transformer;
  70.   private final XmlContentBuilder xmlContentBuilder;
  71.   private final Set<String> folderPaths = new HashSet<>();

  72.   private static final String CONTENT_TYPE_CHARSET_EXTENSION = ";charset=";
  73.   private static final String DOT_DIR_FOLDER = ".dir";

  74.   /**
  75.    * @param os Output stream
  76.    */
  77.   @SuppressWarnings("java:S1141") // nested try-catch
  78.   ContentPackage(PackageMetadata metadata, OutputStream os) throws IOException {
  79.     this.metadata = metadata;
  80.     this.zip = new ZipOutputStream(os);

  81.     TransformerFactory transformerFactory = TransformerFactory.newInstance();
  82.     transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
  83.     transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
  84.     try {
  85.       transformerFactory.setAttribute("indent-number", 2);
  86.     }
  87.     catch (IllegalArgumentException ex) {
  88.       // Implementation does not support configuration property. Ignore.
  89.     }
  90.     try {
  91.       this.transformer = transformerFactory.newTransformer();
  92.       try {
  93.         this.transformer.setOutputProperty(OutputKeys.INDENT, "yes");
  94.       }
  95.       catch (IllegalArgumentException ex) {
  96.         // Implementation does not support output property. Ignore.
  97.       }
  98.       try {
  99.         this.transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
  100.       }
  101.       catch (IllegalArgumentException ex) {
  102.         // Implementation does not support output property. Ignore.
  103.       }
  104.     }
  105.     catch (TransformerException ex) {
  106.       throw new IllegalStateException("Failed to set up XML transformer: " + ex.getMessage(), ex);
  107.     }

  108.     this.xmlContentBuilder = new XmlContentBuilder(metadata.getXmlNamespaces());

  109.     buildPackageMetadata();
  110.   }

  111.   /**
  112.    * Adds a page with given content. The "cq:Page/cq:PageContent envelope" is added automatically.
  113.    * @param path Full content path of page.
  114.    * @param content Hierarchy of content elements.
  115.    * @throws IOException I/O exception
  116.    */
  117.   public void addPage(String path, ContentElement content) throws IOException {
  118.     String fullPath = buildJcrPathForZip(path) + "/" + DOT_CONTENT_XML;
  119.     Document doc = xmlContentBuilder.buildPage(content);
  120.     writeXmlDocument(fullPath, doc);
  121.   }

  122.   /**
  123.    * Adds a page with given content. The "cq:Page/cq:PageContent envelope" is added automatically.
  124.    * @param path Full content path of page.
  125.    * @param content Map with page properties. If the map contains nested maps this builds a tree of JCR nodes.
  126.    *          The key of the nested map in its parent map is the node name,
  127.    *          the nested map contain the properties of the child node.
  128.    * @throws IOException I/O exception
  129.    */
  130.   public void addPage(String path, Map<String, Object> content) throws IOException {
  131.     String fullPath = buildJcrPathForZip(path) + "/" + DOT_CONTENT_XML;
  132.     Document doc = xmlContentBuilder.buildPage(content);
  133.     writeXmlDocument(fullPath, doc);
  134.   }

  135.   /**
  136.    * Add some JCR content structure directly to the package.
  137.    * @param path Full content path of content root node.
  138.    * @param content Hierarchy of content elements.
  139.    * @throws IOException I/O exception
  140.    */
  141.   public void addContent(String path, ContentElement content) throws IOException {
  142.     String basePath = buildJcrPathForZip(path);
  143.     List<ContentPart> parts = ContentFolderSplitter.split(ContentElementConverter.toMap(content));
  144.     for (ContentPart part : parts) {
  145.       String fullPath = basePath + part.getPath() + "/" + DOT_CONTENT_XML;
  146.       Document doc = xmlContentBuilder.buildContent(part.getContent());
  147.       writeXmlDocument(fullPath, doc);
  148.     }
  149.   }

  150.   /**
  151.    * Add some JCR content structure directly to the package.
  152.    * @param path Full content path of content root node.
  153.    * @param content Map with node properties. If the map contains nested maps this builds a tree of JCR nodes.
  154.    *          The key of the nested map in its parent map is the node name,
  155.    *          the nested map contain the properties of the child node.
  156.    * @throws IOException I/O exception
  157.    */
  158.   public void addContent(String path, Map<String, Object> content) throws IOException {
  159.     String basePath = buildJcrPathForZip(path);
  160.     List<ContentPart> parts = ContentFolderSplitter.split(content);
  161.     for (ContentPart part : parts) {
  162.       String fullPath = basePath + part.getPath() + "/" + DOT_CONTENT_XML;
  163.       Document doc = xmlContentBuilder.buildContent(part.getContent());
  164.       writeXmlDocument(fullPath, doc);
  165.     }
  166.   }

  167.   /**
  168.    * Add some JCR content structure directly to the package.
  169.    * <p>
  170.    * This method is used to provide additional properties for a path that is already used by a binary file,
  171.    * using a special <code>&lt;node-name&gt;.dir/.content.xml</code> syntax.
  172.    * </p>
  173.    * @param path Full content path of content root/file node.
  174.    * @param content Hierarchy of content elements.
  175.    * @throws IOException I/O exception
  176.    */
  177.   public void addContentForFile(String path, ContentElement content) throws IOException {
  178.     addContent(path + DOT_DIR_FOLDER, content);
  179.   }

  180.   /**
  181.    * Add some JCR content structure directly to the package.
  182.    * <p>
  183.    * This method is used to provide additional properties for a path that is already used by a binary file,
  184.    * using a special <code>&lt;node-name&gt;.dir/.content.xml</code> syntax.
  185.    * </p>
  186.    * @param path Full content path of content root/file node.
  187.    * @param content Map with node properties. If the map contains nested maps this builds a tree of JCR nodes.
  188.    *          The key of the nested map in its parent map is the node name,
  189.    *          the nested map contain the properties of the child node.
  190.    * @throws IOException I/O exception
  191.    */
  192.   public void addContentForFile(String path, Map<String, Object> content) throws IOException {
  193.     addContent(path + DOT_DIR_FOLDER, content);
  194.   }

  195.   /**
  196.    * Adds a binary file.
  197.    * @param path Full content path and file name of file
  198.    * @param inputStream Input stream with binary dta
  199.    * @throws IOException I/O exception
  200.    */
  201.   public void addFile(String path, InputStream inputStream) throws IOException {
  202.     addFile(path, inputStream, null);
  203.   }

  204.   /**
  205.    * Adds a binary file with explicit mime type.
  206.    * @param path Full content path and file name of file
  207.    * @param inputStream Input stream with binary data
  208.    * @param contentType Mime type, optionally with ";charset=XYZ" extension
  209.    * @throws IOException I/O exception
  210.    */
  211.   public void addFile(String path, InputStream inputStream, String contentType) throws IOException {
  212.     String fullPath = buildJcrPathForZip(path);
  213.     writeBinaryFile(fullPath, inputStream);

  214.     if (StringUtils.isNotEmpty(contentType)) {
  215.       String mimeType = StringUtils.substringBefore(contentType, CONTENT_TYPE_CHARSET_EXTENSION);
  216.       String encoding = StringUtils.substringAfter(contentType, CONTENT_TYPE_CHARSET_EXTENSION);

  217.       String fullPathMetadata = fullPath + DOT_DIR_FOLDER + "/" + DOT_CONTENT_XML;
  218.       Document doc = xmlContentBuilder.buildNtFile(mimeType, encoding);
  219.       writeXmlDocument(fullPathMetadata, doc);
  220.     }
  221.   }

  222.   /**
  223.    * If path parts contain namespace definitions they need to be escaped for the ZIP file.
  224.    * Example: oak:index -> jcr_root/_oak_index
  225.    * @param path Path
  226.    * @return Safe path
  227.    */
  228.   @SuppressWarnings("PMD.UseStringBufferForStringAppends")
  229.   static String buildJcrPathForZip(final String path) {
  230.     String normalizedPath = StringUtils.defaultString(path);
  231.     if (!normalizedPath.startsWith("/")) {
  232.       normalizedPath = "/" + normalizedPath;
  233.     }
  234.     ensureValidPath(path);
  235.     return ROOT_DIR + PlatformNameFormat.getPlatformPath(normalizedPath);
  236.   }

  237.   /**
  238.    * Adds a binary file.
  239.    * @param path Full content path and file name of file
  240.    * @param file File with binary data
  241.    * @throws IOException I/O exception
  242.    */
  243.   public void addFile(String path, File file) throws IOException {
  244.     addFile(path, file, null);
  245.   }

  246.   /**
  247.    * Adds a binary file with explicit mime type.
  248.    * @param path Full content path and file name of file
  249.    * @param file File with binary data
  250.    * @param contentType Mime type, optionally with ";charset=XYZ" extension
  251.    * @throws IOException I/O exception
  252.    */
  253.   public void addFile(String path, File file, String contentType) throws IOException {
  254.     try (InputStream is = new FileInputStream(file)) {
  255.       addFile(path, is, contentType);
  256.     }
  257.   }

  258.   /**
  259.    * Close the underlying ZIP stream of the package.
  260.    * @throws IOException I/O exception
  261.    */
  262.   @Override
  263.   public void close() throws IOException {
  264.     zip.flush();
  265.     zip.close();
  266.   }

  267.   /**
  268.    * Get root path of the package. This does only work if there is only one filter of the package.
  269.    * If they are more filters use {@link #getFilters()} instead.
  270.    * @return Root path of package
  271.    */
  272.   public String getRootPath() {
  273.     if (metadata.getFilters().size() == 1) {
  274.       return metadata.getFilters().get(0).getRootPath();
  275.     }
  276.     else {
  277.       throw new IllegalStateException("Content package has more than one package filter - please use getFilters().");
  278.     }
  279.   }

  280.   /**
  281.    * Get filters defined for this package.
  282.    * @return List of package filters, optionally with include/exclude rules.
  283.    */
  284.   public List<PackageFilter> getFilters() {
  285.     return metadata.getFilters();
  286.   }

  287.   /**
  288.    * Build all package metadata files based on templates.
  289.    * @throws IOException I/O exception
  290.    */
  291.   private void buildPackageMetadata() throws IOException {
  292.     metadata.validate();
  293.     buildTemplatedMetadataFile(META_DIR + "/" + CONFIG_XML);
  294.     buildPropertiesFile(META_DIR + "/" + PROPERTIES_XML);
  295.     buildTemplatedMetadataFile(META_DIR + "/" + SETTINGS_XML);
  296.     buildTemplatedMetadataFile(META_DIR + "/" + PACKAGE_DEFINITION_XML);
  297.     writeXmlDocument(META_DIR + "/" + FILTER_XML, xmlContentBuilder.buildFilter(metadata.getFilters()));

  298.     // package thumbnail
  299.     byte[] thumbnailImage = metadata.getThumbnailImage();
  300.     if (thumbnailImage != null) {
  301.       zipPutNextFileEntry(META_DIR + "/definition/thumbnail.png");
  302.       try {
  303.         zip.write(thumbnailImage);
  304.       }
  305.       finally {
  306.         zip.closeEntry();
  307.       }
  308.     }
  309.   }

  310.   /**
  311.    * Read template file from classpath, replace variables and store it in the zip stream.
  312.    * @param path Path
  313.    * @throws IOException I/O exception
  314.    */
  315.   @SuppressWarnings("deprecation")
  316.   private void buildTemplatedMetadataFile(String path) throws IOException {
  317.     try (InputStream is = getClass().getResourceAsStream("/content-package-template/" + path)) {
  318.       String xmlContent = IOUtils.toString(is, StandardCharsets.UTF_8);
  319.       for (Map.Entry<String, Object> entry : metadata.getVars().entrySet()) {
  320.         xmlContent = StringUtils.replace(xmlContent, "{{" + entry.getKey() + "}}",
  321.             org.apache.commons.lang3.StringEscapeUtils.escapeXml10(entry.getValue().toString()));
  322.       }
  323.       zipPutNextFileEntry(path);
  324.       try {
  325.         zip.write(xmlContent.getBytes(StandardCharsets.UTF_8));
  326.       }
  327.       finally {
  328.         zip.closeEntry();
  329.       }
  330.     }
  331.   }

  332.   /**
  333.    * Build java Properties XML file.
  334.    * @param path Path
  335.    * @throws IOException I/O exception
  336.    */
  337.   private void buildPropertiesFile(String path) throws IOException {
  338.     Properties properties = new Properties();
  339.     properties.put(PackageProperties.NAME_REQUIRES_ROOT, Boolean.toString(false));
  340.     properties.put("allowIndexDefinitions", Boolean.toString(false));

  341.     for (Map.Entry<String, Object> entry : metadata.getVars().entrySet()) {
  342.       String value = Objects.toString(entry.getValue());
  343.       if (StringUtils.isNotEmpty(value)) {
  344.         properties.put(entry.getKey(), value);
  345.       }
  346.     }

  347.     zipPutNextFileEntry(path);
  348.     try {
  349.       properties.storeToXML(zip, null);
  350.     }
  351.     finally {
  352.       zip.closeEntry();
  353.     }
  354.   }

  355.   /**
  356.    * Writes an XML document as binary file entry to the ZIP output stream.
  357.    * @param path Content path
  358.    * @param doc XML content
  359.    * @throws IOException I/O exception
  360.    */
  361.   private void writeXmlDocument(String path, Document doc) throws IOException {
  362.     zipPutNextFileEntry(path);
  363.     try {
  364.       DOMSource source = new DOMSource(doc);
  365.       StreamResult result = new StreamResult(zip);
  366.       transformer.transform(source, result);
  367.     }
  368.     catch (TransformerException ex) {
  369.       throw new IOException("Failed to generate XML: " + ex.getMessage(), ex);
  370.     }
  371.     finally {
  372.       zip.closeEntry();
  373.     }
  374.   }

  375.   /**
  376.    * Writes an binary file entry to the ZIP output stream.
  377.    * @param path Content path
  378.    * @param is Input stream with binary data
  379.    * @throws IOException I/O exception
  380.    */
  381.   private void writeBinaryFile(String path, InputStream is) throws IOException {
  382.     zipPutNextFileEntry(path);
  383.     try {
  384.       IOUtils.copy(is, zip);
  385.     }
  386.     finally {
  387.       zip.closeEntry();
  388.     }
  389.   }

  390.   /**
  391.    * Creates a new ZIP entry for a file with given paths.
  392.    * Ensures that entries for the parent folders are created before.
  393.    * @param path File path
  394.    * @throws IOException I/O exception
  395.    */
  396.   private void zipPutNextFileEntry(@NotNull String path) throws IOException {
  397.     String folderPath = FilenameUtils.getPath(path);
  398.     ensureFolderPaths(folderPath);
  399.     zip.putNextEntry(new ZipEntry(path));
  400.   }

  401.   /**
  402.    * Ensures that zip entries for the given folder and it's parend folders (if they do not exist already).
  403.    * @param folderPath Folder path
  404.    * @throws IOException I/O exception
  405.    */
  406.   private void ensureFolderPaths(@NotNull String folderPath) throws IOException {
  407.     if (folderPaths.contains(folderPath) || StringUtils.isEmpty(folderPath) || StringUtils.equals(folderPath, "/")) {
  408.       // skip paths already created and root folder
  409.       return;
  410.     }
  411.     // ensure parent folders
  412.     String parentFolderPath = FilenameUtils.getPath(StringUtils.removeEnd(folderPath, "/"));
  413.     ensureFolderPaths(parentFolderPath);
  414.     // create folder ZIP entry
  415.     zip.putNextEntry(new ZipEntry(folderPath));
  416.     folderPaths.add(folderPath);
  417.   }

  418. }