ContentPackage.java
/*
* #%L
* wcm.io
* %%
* Copyright (C) 2015 wcm.io
* %%
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* #L%
*/
package io.wcm.tooling.commons.contentpackagebuilder;
import static io.wcm.tooling.commons.contentpackagebuilder.NameUtil.ensureValidPath;
import static org.apache.jackrabbit.vault.util.Constants.CONFIG_XML;
import static org.apache.jackrabbit.vault.util.Constants.DOT_CONTENT_XML;
import static org.apache.jackrabbit.vault.util.Constants.FILTER_XML;
import static org.apache.jackrabbit.vault.util.Constants.META_DIR;
import static org.apache.jackrabbit.vault.util.Constants.PACKAGE_DEFINITION_XML;
import static org.apache.jackrabbit.vault.util.Constants.PROPERTIES_XML;
import static org.apache.jackrabbit.vault.util.Constants.ROOT_DIR;
import static org.apache.jackrabbit.vault.util.Constants.SETTINGS_XML;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.xml.XMLConstants;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.jackrabbit.vault.packaging.PackageProperties;
import org.apache.jackrabbit.vault.util.PlatformNameFormat;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import io.wcm.tooling.commons.contentpackagebuilder.ContentFolderSplitter.ContentPart;
import io.wcm.tooling.commons.contentpackagebuilder.element.ContentElement;
/**
* Represents an AEM content package.
* Content like structured JCR data and binary files can be added.
* This class is not thread-safe.
*/
public final class ContentPackage implements Closeable {
private final PackageMetadata metadata;
private final ZipOutputStream zip;
private final Transformer transformer;
private final XmlContentBuilder xmlContentBuilder;
private final Set<String> folderPaths = new HashSet<>();
private static final String CONTENT_TYPE_CHARSET_EXTENSION = ";charset=";
private static final String DOT_DIR_FOLDER = ".dir";
/**
* @param os Output stream
*/
@SuppressWarnings("java:S1141") // nested try-catch
ContentPackage(PackageMetadata metadata, OutputStream os) throws IOException {
this.metadata = metadata;
this.zip = new ZipOutputStream(os);
TransformerFactory transformerFactory = TransformerFactory.newInstance();
transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
try {
transformerFactory.setAttribute("indent-number", 2);
}
catch (IllegalArgumentException ex) {
// Implementation does not support configuration property. Ignore.
}
try {
this.transformer = transformerFactory.newTransformer();
try {
this.transformer.setOutputProperty(OutputKeys.INDENT, "yes");
}
catch (IllegalArgumentException ex) {
// Implementation does not support output property. Ignore.
}
try {
this.transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
}
catch (IllegalArgumentException ex) {
// Implementation does not support output property. Ignore.
}
}
catch (TransformerException ex) {
throw new IllegalStateException("Failed to set up XML transformer: " + ex.getMessage(), ex);
}
this.xmlContentBuilder = new XmlContentBuilder(metadata.getXmlNamespaces());
buildPackageMetadata();
}
/**
* Adds a page with given content. The "cq:Page/cq:PageContent envelope" is added automatically.
* @param path Full content path of page.
* @param content Hierarchy of content elements.
* @throws IOException I/O exception
*/
public void addPage(String path, ContentElement content) throws IOException {
String fullPath = buildJcrPathForZip(path) + "/" + DOT_CONTENT_XML;
Document doc = xmlContentBuilder.buildPage(content);
writeXmlDocument(fullPath, doc);
}
/**
* Adds a page with given content. The "cq:Page/cq:PageContent envelope" is added automatically.
* @param path Full content path of page.
* @param content Map with page properties. If the map contains nested maps this builds a tree of JCR nodes.
* The key of the nested map in its parent map is the node name,
* the nested map contain the properties of the child node.
* @throws IOException I/O exception
*/
public void addPage(String path, Map<String, Object> content) throws IOException {
String fullPath = buildJcrPathForZip(path) + "/" + DOT_CONTENT_XML;
Document doc = xmlContentBuilder.buildPage(content);
writeXmlDocument(fullPath, doc);
}
/**
* Add some JCR content structure directly to the package.
* @param path Full content path of content root node.
* @param content Hierarchy of content elements.
* @throws IOException I/O exception
*/
public void addContent(String path, ContentElement content) throws IOException {
String basePath = buildJcrPathForZip(path);
List<ContentPart> parts = ContentFolderSplitter.split(ContentElementConverter.toMap(content));
for (ContentPart part : parts) {
String fullPath = basePath + part.getPath() + "/" + DOT_CONTENT_XML;
Document doc = xmlContentBuilder.buildContent(part.getContent());
writeXmlDocument(fullPath, doc);
}
}
/**
* Add some JCR content structure directly to the package.
* @param path Full content path of content root node.
* @param content Map with node properties. If the map contains nested maps this builds a tree of JCR nodes.
* The key of the nested map in its parent map is the node name,
* the nested map contain the properties of the child node.
* @throws IOException I/O exception
*/
public void addContent(String path, Map<String, Object> content) throws IOException {
String basePath = buildJcrPathForZip(path);
List<ContentPart> parts = ContentFolderSplitter.split(content);
for (ContentPart part : parts) {
String fullPath = basePath + part.getPath() + "/" + DOT_CONTENT_XML;
Document doc = xmlContentBuilder.buildContent(part.getContent());
writeXmlDocument(fullPath, doc);
}
}
/**
* Add some JCR content structure directly to the package.
* <p>
* This method is used to provide additional properties for a path that is already used by a binary file,
* using a special <code><node-name>.dir/.content.xml</code> syntax.
* </p>
* @param path Full content path of content root/file node.
* @param content Hierarchy of content elements.
* @throws IOException I/O exception
*/
public void addContentForFile(String path, ContentElement content) throws IOException {
addContent(path + DOT_DIR_FOLDER, content);
}
/**
* Add some JCR content structure directly to the package.
* <p>
* This method is used to provide additional properties for a path that is already used by a binary file,
* using a special <code><node-name>.dir/.content.xml</code> syntax.
* </p>
* @param path Full content path of content root/file node.
* @param content Map with node properties. If the map contains nested maps this builds a tree of JCR nodes.
* The key of the nested map in its parent map is the node name,
* the nested map contain the properties of the child node.
* @throws IOException I/O exception
*/
public void addContentForFile(String path, Map<String, Object> content) throws IOException {
addContent(path + DOT_DIR_FOLDER, content);
}
/**
* Adds a binary file.
* @param path Full content path and file name of file
* @param inputStream Input stream with binary dta
* @throws IOException I/O exception
*/
public void addFile(String path, InputStream inputStream) throws IOException {
addFile(path, inputStream, null);
}
/**
* Adds a binary file with explicit mime type.
* @param path Full content path and file name of file
* @param inputStream Input stream with binary data
* @param contentType Mime type, optionally with ";charset=XYZ" extension
* @throws IOException I/O exception
*/
public void addFile(String path, InputStream inputStream, String contentType) throws IOException {
String fullPath = buildJcrPathForZip(path);
writeBinaryFile(fullPath, inputStream);
if (StringUtils.isNotEmpty(contentType)) {
String mimeType = StringUtils.substringBefore(contentType, CONTENT_TYPE_CHARSET_EXTENSION);
String encoding = StringUtils.substringAfter(contentType, CONTENT_TYPE_CHARSET_EXTENSION);
String fullPathMetadata = fullPath + DOT_DIR_FOLDER + "/" + DOT_CONTENT_XML;
Document doc = xmlContentBuilder.buildNtFile(mimeType, encoding);
writeXmlDocument(fullPathMetadata, doc);
}
}
/**
* If path parts contain namespace definitions they need to be escaped for the ZIP file.
* Example: oak:index -> jcr_root/_oak_index
* @param path Path
* @return Safe path
*/
@SuppressWarnings("PMD.UseStringBufferForStringAppends")
static String buildJcrPathForZip(final String path) {
String normalizedPath = StringUtils.defaultString(path);
if (!normalizedPath.startsWith("/")) {
normalizedPath = "/" + normalizedPath;
}
ensureValidPath(path);
return ROOT_DIR + PlatformNameFormat.getPlatformPath(normalizedPath);
}
/**
* Adds a binary file.
* @param path Full content path and file name of file
* @param file File with binary data
* @throws IOException I/O exception
*/
public void addFile(String path, File file) throws IOException {
addFile(path, file, null);
}
/**
* Adds a binary file with explicit mime type.
* @param path Full content path and file name of file
* @param file File with binary data
* @param contentType Mime type, optionally with ";charset=XYZ" extension
* @throws IOException I/O exception
*/
public void addFile(String path, File file, String contentType) throws IOException {
try (InputStream is = new FileInputStream(file)) {
addFile(path, is, contentType);
}
}
/**
* Close the underlying ZIP stream of the package.
* @throws IOException I/O exception
*/
@Override
public void close() throws IOException {
zip.flush();
zip.close();
}
/**
* Get root path of the package. This does only work if there is only one filter of the package.
* If they are more filters use {@link #getFilters()} instead.
* @return Root path of package
*/
public String getRootPath() {
if (metadata.getFilters().size() == 1) {
return metadata.getFilters().get(0).getRootPath();
}
else {
throw new IllegalStateException("Content package has more than one package filter - please use getFilters().");
}
}
/**
* Get filters defined for this package.
* @return List of package filters, optionally with include/exclude rules.
*/
public List<PackageFilter> getFilters() {
return metadata.getFilters();
}
/**
* Build all package metadata files based on templates.
* @throws IOException I/O exception
*/
private void buildPackageMetadata() throws IOException {
metadata.validate();
buildTemplatedMetadataFile(META_DIR + "/" + CONFIG_XML);
buildPropertiesFile(META_DIR + "/" + PROPERTIES_XML);
buildTemplatedMetadataFile(META_DIR + "/" + SETTINGS_XML);
buildTemplatedMetadataFile(META_DIR + "/" + PACKAGE_DEFINITION_XML);
writeXmlDocument(META_DIR + "/" + FILTER_XML, xmlContentBuilder.buildFilter(metadata.getFilters()));
// package thumbnail
byte[] thumbnailImage = metadata.getThumbnailImage();
if (thumbnailImage != null) {
zipPutNextFileEntry(META_DIR + "/definition/thumbnail.png");
try {
zip.write(thumbnailImage);
}
finally {
zip.closeEntry();
}
}
}
/**
* Read template file from classpath, replace variables and store it in the zip stream.
* @param path Path
* @throws IOException I/O exception
*/
@SuppressWarnings("deprecation")
private void buildTemplatedMetadataFile(String path) throws IOException {
try (InputStream is = getClass().getResourceAsStream("/content-package-template/" + path)) {
String xmlContent = IOUtils.toString(is, StandardCharsets.UTF_8);
for (Map.Entry<String, Object> entry : metadata.getVars().entrySet()) {
xmlContent = StringUtils.replace(xmlContent, "{{" + entry.getKey() + "}}",
org.apache.commons.lang3.StringEscapeUtils.escapeXml10(entry.getValue().toString()));
}
zipPutNextFileEntry(path);
try {
zip.write(xmlContent.getBytes(StandardCharsets.UTF_8));
}
finally {
zip.closeEntry();
}
}
}
/**
* Build java Properties XML file.
* @param path Path
* @throws IOException I/O exception
*/
private void buildPropertiesFile(String path) throws IOException {
Properties properties = new Properties();
properties.put(PackageProperties.NAME_REQUIRES_ROOT, Boolean.toString(false));
properties.put("allowIndexDefinitions", Boolean.toString(false));
for (Map.Entry<String, Object> entry : metadata.getVars().entrySet()) {
String value = Objects.toString(entry.getValue());
if (StringUtils.isNotEmpty(value)) {
properties.put(entry.getKey(), value);
}
}
zipPutNextFileEntry(path);
try {
properties.storeToXML(zip, null);
}
finally {
zip.closeEntry();
}
}
/**
* Writes an XML document as binary file entry to the ZIP output stream.
* @param path Content path
* @param doc XML content
* @throws IOException I/O exception
*/
private void writeXmlDocument(String path, Document doc) throws IOException {
zipPutNextFileEntry(path);
try {
DOMSource source = new DOMSource(doc);
StreamResult result = new StreamResult(zip);
transformer.transform(source, result);
}
catch (TransformerException ex) {
throw new IOException("Failed to generate XML: " + ex.getMessage(), ex);
}
finally {
zip.closeEntry();
}
}
/**
* Writes an binary file entry to the ZIP output stream.
* @param path Content path
* @param is Input stream with binary data
* @throws IOException I/O exception
*/
private void writeBinaryFile(String path, InputStream is) throws IOException {
zipPutNextFileEntry(path);
try {
IOUtils.copy(is, zip);
}
finally {
zip.closeEntry();
}
}
/**
* Creates a new ZIP entry for a file with given paths.
* Ensures that entries for the parent folders are created before.
* @param path File path
* @throws IOException I/O exception
*/
private void zipPutNextFileEntry(@NotNull String path) throws IOException {
String folderPath = FilenameUtils.getPath(path);
ensureFolderPaths(folderPath);
zip.putNextEntry(new ZipEntry(path));
}
/**
* Ensures that zip entries for the given folder and it's parend folders (if they do not exist already).
* @param folderPath Folder path
* @throws IOException I/O exception
*/
private void ensureFolderPaths(@NotNull String folderPath) throws IOException {
if (folderPaths.contains(folderPath) || StringUtils.isEmpty(folderPath) || StringUtils.equals(folderPath, "/")) {
// skip paths already created and root folder
return;
}
// ensure parent folders
String parentFolderPath = FilenameUtils.getPath(StringUtils.removeEnd(folderPath, "/"));
ensureFolderPaths(parentFolderPath);
// create folder ZIP entry
zip.putNextEntry(new ZipEntry(folderPath));
folderPaths.add(folderPath);
}
}