1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 package io.wcm.tooling.commons.packmgr.unpack;
21
22 import static org.apache.jackrabbit.vault.util.Constants.DOT_CONTENT_XML;
23 import static org.apache.jackrabbit.vault.util.Constants.ROOT_DIR;
24
25 import java.io.File;
26 import java.io.IOException;
27 import java.io.InputStream;
28 import java.io.OutputStream;
29 import java.nio.file.Files;
30 import java.nio.file.Path;
31 import java.util.ArrayList;
32 import java.util.Calendar;
33 import java.util.Enumeration;
34 import java.util.HashSet;
35 import java.util.LinkedHashSet;
36 import java.util.List;
37 import java.util.Set;
38 import java.util.TreeSet;
39 import java.util.concurrent.atomic.AtomicBoolean;
40 import java.util.regex.Matcher;
41 import java.util.regex.Pattern;
42 import java.util.regex.PatternSyntaxException;
43
44 import javax.jcr.PropertyType;
45 import javax.xml.XMLConstants;
46 import javax.xml.parsers.ParserConfigurationException;
47 import javax.xml.parsers.SAXParser;
48 import javax.xml.parsers.SAXParserFactory;
49
50 import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
51 import org.apache.commons.compress.archivers.zip.ZipFile;
52 import org.apache.commons.io.FilenameUtils;
53 import org.apache.commons.lang3.StringUtils;
54 import org.apache.commons.lang3.Strings;
55 import org.apache.jackrabbit.JcrConstants;
56 import org.apache.jackrabbit.util.ISO8601;
57 import org.apache.jackrabbit.vault.fs.io.DocViewFormat;
58 import org.apache.jackrabbit.vault.util.PlatformNameFormat;
59 import org.jdom2.Attribute;
60 import org.jdom2.Document;
61 import org.jdom2.Element;
62 import org.jdom2.JDOMException;
63 import org.jdom2.Namespace;
64 import org.jdom2.input.SAXBuilder;
65 import org.jdom2.output.Format;
66 import org.jdom2.output.LineSeparator;
67 import org.jdom2.output.XMLOutputter;
68 import org.jetbrains.annotations.Nullable;
69 import org.xml.sax.Attributes;
70 import org.xml.sax.SAXException;
71 import org.xml.sax.helpers.DefaultHandler;
72
73 import io.wcm.tooling.commons.packmgr.PackageManagerException;
74
75
76
77
78 public final class ContentUnpacker {
79
80 private static final String MIXINS_PROPERTY = "jcr:mixinTypes";
81 private static final String PRIMARYTYPE_PROPERTY = "jcr:primaryType";
82 private static final Namespace JCR_NAMESPACE = Namespace.getNamespace("jcr", "http://www.jcp.org/jcr/1.0");
83 private static final Namespace CQ_NAMESPACE = Namespace.getNamespace("cq", "http://www.day.com/jcr/cq/1.0");
84 private static final Pattern FILENAME_NAMESPACE_PATTERN = Pattern.compile("^([^:]+):(.+)$");
85
86 private static final SAXParserFactory SAX_PARSER_FACTORY;
87 static {
88 SAX_PARSER_FACTORY = SAXParserFactory.newInstance();
89 SAX_PARSER_FACTORY.setNamespaceAware(true);
90 }
91
92 private static final DocViewFormat DOCVIEWFORMAT = new DocViewFormat();
93
94 private final Pattern[] excludeFiles;
95 private final Pattern[] excludeNodes;
96 private final Pattern[] excludeProperties;
97 private final Pattern[] excludeMixins;
98 private final boolean markReplicationActivated;
99 private final Pattern[] markReplicationActivatedIncludeNodes;
100 private final String dateLastReplicated;
101
102
103
104
105
106 public ContentUnpacker(ContentUnpackerProperties properties) {
107 this.excludeFiles = toPatternArray(properties.getExcludeFiles());
108 this.excludeNodes = toPatternArray(properties.getExcludeNodes());
109 this.excludeProperties = toPatternArray(properties.getExcludeProperties());
110 this.excludeMixins = toPatternArray(properties.getExcludeMixins());
111 this.markReplicationActivated = properties.isMarkReplicationActivated();
112 this.markReplicationActivatedIncludeNodes = toPatternArray(properties.getMarkReplicationActivatedIncludeNodes());
113
114 if (StringUtils.isNotBlank(properties.getDateLastReplicated())) {
115 this.dateLastReplicated = properties.getDateLastReplicated();
116 }
117 else {
118
119 Calendar cal = Calendar.getInstance();
120 cal.set(Calendar.HOUR_OF_DAY, 0);
121 cal.set(Calendar.MINUTE, 0);
122 cal.set(Calendar.SECOND, 0);
123 cal.set(Calendar.MILLISECOND, 0);
124 this.dateLastReplicated = ISO8601.format(cal);
125 }
126 }
127
128 private static Pattern[] toPatternArray(String[] patternStrings) {
129 if (patternStrings == null) {
130 return new Pattern[0];
131 }
132 Pattern[] patterns = new Pattern[patternStrings.length];
133 for (int i = 0; i < patternStrings.length; i++) {
134 try {
135 patterns[i] = Pattern.compile(patternStrings[i]);
136 }
137 catch (PatternSyntaxException ex) {
138 throw new PackageManagerException("Invalid regexp pattern: " + patternStrings[i], ex);
139 }
140 }
141 return patterns;
142 }
143
144 private static boolean matches(String name, Pattern[] patterns, boolean defaultIfNotPatternsDefined) {
145 if (patterns.length == 0) {
146 return defaultIfNotPatternsDefined;
147 }
148 for (Pattern pattern : patterns) {
149 if (pattern.matcher(name).matches()) {
150 return true;
151 }
152 }
153 return false;
154 }
155
156 private boolean applyXmlExcludes(String name) {
157 if (this.excludeNodes.length == 0 && this.excludeProperties.length == 0) {
158 return false;
159 }
160 return isJcrContentXmlFile(name);
161 }
162
163 private boolean isJcrContentXmlFile(String name) {
164 return Strings.CI.equals(FilenameUtils.getExtension(name), "xml")
165 && Strings.CS.startsWith(name, "jcr_root/");
166 }
167
168
169
170
171
172
173 public void unpack(File file, File outputDirectory) {
174 Path outputDirectoryPath = outputDirectory.toPath();
175 long entryCount = 0;
176 long totalBytes = 0;
177 try (ZipFile zipFile = new ZipFile.Builder().setFile(file).get()) {
178 Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
179 while (entries.hasMoreElements()) {
180 ZipArchiveEntry entry = entries.nextElement();
181 if (!matches(entry.getName(), excludeFiles, false)) {
182 entryCount++;
183 SafeExtract.checkEntryCount(entryCount);
184 totalBytes = unpackEntry(zipFile, entry, outputDirectoryPath, totalBytes);
185 }
186 }
187 }
188 catch (IOException ex) {
189 throw new PackageManagerException("Error reading content package " + file.getAbsolutePath(), ex);
190 }
191 }
192
193 @SuppressWarnings("java:S3776")
194 private long unpackEntry(ZipFile zipFile, ZipArchiveEntry entry, Path outputDirectory, long bytesWrittenSoFar) throws IOException {
195
196 Path entryPath = SafeExtract.resolveSafely(outputDirectory, entry.getName());
197 if (entry.isDirectory()) {
198 Files.createDirectories(entryPath);
199 return bytesWrittenSoFar;
200 }
201 else {
202 Set<String> namespacePrefixes = null;
203 if (applyXmlExcludes(entry.getName())) {
204 namespacePrefixes = getNamespacePrefixes(zipFile, entry);
205 }
206
207 long totalBytes = bytesWrittenSoFar;
208 try (InputStream entryStream = zipFile.getInputStream(entry)) {
209 Files.deleteIfExists(entryPath);
210 Path directory = entryPath.getParent();
211 if (directory != null) {
212 Files.createDirectories(directory);
213 }
214
215 try (OutputStream fos = Files.newOutputStream(entryPath)) {
216 if (applyXmlExcludes(entry.getName()) && namespacePrefixes != null) {
217
218 try {
219 writeXmlWithExcludes(entry, entryStream, fos, namespacePrefixes);
220 }
221 catch (JDOMException ex) {
222 throw new PackageManagerException("Unable to parse XML file: " + entry.getName(), ex);
223 }
224 }
225 else {
226
227 totalBytes = SafeExtract.copyWithLimit(entryStream, fos, totalBytes);
228 }
229 }
230 if (isJcrContentXmlFile(entry.getName())) {
231
232 File outputFile = entryPath.toFile();
233 try {
234 DOCVIEWFORMAT.format(outputFile, false);
235 }
236 catch (IOException ex) {
237 throw new IOException("Unable to apply DocView format to file: " + outputFile.getAbsolutePath(), ex);
238 }
239 }
240 }
241 return totalBytes;
242 }
243 }
244
245
246
247
248
249
250
251
252
253 private @Nullable Set<String> getNamespacePrefixes(ZipFile zipFile, ZipArchiveEntry entry) throws IOException {
254 try (InputStream entryStream = zipFile.getInputStream(entry)) {
255 SAXParser parser = SAX_PARSER_FACTORY.newSAXParser();
256 final Set<String> prefixes = new LinkedHashSet<>();
257
258 final AtomicBoolean foundRootElement = new AtomicBoolean(false);
259 DefaultHandler handler = new DefaultHandler() {
260
261 @Override
262 public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
263
264 if (Strings.CS.equals(uri, JCR_NAMESPACE.getURI()) && Strings.CS.equals(localName, "root")) {
265 foundRootElement.set(true);
266 }
267 }
268
269 @Override
270 public void startPrefixMapping(String prefix, String uri) throws SAXException {
271 if (StringUtils.isNotBlank(prefix)) {
272 prefixes.add(prefix);
273 }
274 }
275 };
276 parser.parse(entryStream, handler);
277
278 if (!foundRootElement.get()) {
279 return null;
280 }
281 else {
282 return prefixes;
283 }
284 }
285 catch (IOException | SAXException | ParserConfigurationException ex) {
286 throw new IOException("Error parsing " + entry.getName(), ex);
287 }
288 }
289
290 private void writeXmlWithExcludes(ZipArchiveEntry entry, InputStream inputStream, OutputStream outputStream, Set<String> namespacePrefixes)
291 throws IOException, JDOMException {
292 SAXBuilder saxBuilder = new SAXBuilder();
293 saxBuilder.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "");
294 saxBuilder.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
295 Document doc = saxBuilder.build(inputStream);
296
297 Set<String> namespacePrefixesActuallyUsed = new HashSet<>();
298
299
300 String namespacePrefix = getNamespacePrefix(entry.getName());
301 if (namespacePrefix != null) {
302 namespacePrefixesActuallyUsed.add(namespacePrefix);
303 }
304
305 applyXmlExcludes(doc.getRootElement(), getParentPath(entry), namespacePrefixesActuallyUsed, false);
306
307 XMLOutputter outputter = new XMLOutputter(Format.getPrettyFormat()
308 .setIndent(" ")
309 .setLineSeparator(LineSeparator.UNIX));
310 outputter.setXMLOutputProcessor(new NamspaceOrderedXmlProcessor(namespacePrefixes, namespacePrefixesActuallyUsed));
311 outputter.output(doc, outputStream);
312 outputStream.flush();
313 }
314
315 static String getNamespacePrefix(String path) {
316 String fileName = FilenameUtils.getName(path);
317 if (Strings.CS.equals(DOT_CONTENT_XML, fileName)) {
318 String parentFolderName = FilenameUtils.getName(FilenameUtils.getPathNoEndSeparator(path));
319 if (parentFolderName != null) {
320 String nodeName = PlatformNameFormat.getRepositoryName(parentFolderName);
321 Matcher matcher = FILENAME_NAMESPACE_PATTERN.matcher(nodeName);
322 if (matcher.matches()) {
323 return matcher.group(1);
324 }
325 }
326 }
327 return null;
328 }
329
330 private String getParentPath(ZipArchiveEntry entry) {
331 return Strings.CS.removeEnd(Strings.CS.removeStart(entry.getName(), ROOT_DIR), "/" + DOT_CONTENT_XML);
332 }
333
334 private String buildElementPath(Element element, String parentPath) {
335 StringBuilder path = new StringBuilder(parentPath);
336 if (!Strings.CS.equals(element.getQualifiedName(), "jcr:root")) {
337 path.append("/").append(element.getQualifiedName());
338 }
339 return path.toString();
340 }
341
342 @SuppressWarnings({
343 "PMD.EmptyControlStatement",
344 "java:S3776", "java:S6541"
345 })
346 private void applyXmlExcludes(Element element, String parentPath, Set<String> namespacePrefixesActuallyUsed,
347 boolean insideReplicationElement) {
348 String path = buildElementPath(element, parentPath);
349 if (matches(path, this.excludeNodes, false)) {
350 element.detach();
351 return;
352 }
353 collectNamespacePrefix(namespacePrefixesActuallyUsed, element.getNamespacePrefix());
354
355 String jcrPrimaryType = element.getAttributeValue("primaryType", JCR_NAMESPACE);
356 boolean isRepositoryUserGroup = Strings.CS.equals(jcrPrimaryType, "rep:User") || Strings.CS.equals(jcrPrimaryType, "rep:Group");
357 boolean isReplicationElement = Strings.CS.equals(jcrPrimaryType, "cq:Page")
358 || Strings.CS.equals(jcrPrimaryType, "dam:Asset")
359 || Strings.CS.equals(jcrPrimaryType, "cq:Template");
360 boolean isContent = insideReplicationElement && Strings.CS.equals(element.getQualifiedName(), "jcr:content");
361 boolean setReplicationAttributes = isContent && markReplicationActivated;
362
363 List<Attribute> attributes = new ArrayList<>(element.getAttributes());
364 for (Attribute attribute : attributes) {
365 boolean excluded = false;
366 if (matches(attribute.getQualifiedName(), this.excludeProperties, false)) {
367 if (isRepositoryUserGroup && Strings.CS.equals(attribute.getQualifiedName(), JcrConstants.JCR_UUID)) {
368
369 }
370 else {
371 attribute.detach();
372 excluded = true;
373 }
374 }
375 else if (Strings.CS.equals(attribute.getQualifiedName(), PRIMARYTYPE_PROPERTY)) {
376 String namespacePrefix = StringUtils.substringBefore(attribute.getValue(), ":");
377 collectNamespacePrefix(namespacePrefixesActuallyUsed, namespacePrefix);
378 }
379 else if (Strings.CS.equals(attribute.getQualifiedName(), MIXINS_PROPERTY)) {
380 String filteredValue = filterMixinsPropertyValue(attribute.getValue(), namespacePrefixesActuallyUsed);
381 if (StringUtils.isBlank(filteredValue)) {
382 attribute.detach();
383 }
384 else {
385 attribute.setValue(filteredValue);
386 }
387 }
388 else if (Strings.CS.startsWith(attribute.getValue(), "{Name}")) {
389 collectNamespacePrefixNameArray(namespacePrefixesActuallyUsed, attribute.getValue());
390
391 attribute.setValue(sortReferenceValues(attribute.getValue(), PropertyType.NAME));
392 }
393 else if (Strings.CS.startsWith(attribute.getValue(), "{WeakReference}")) {
394
395 attribute.setValue(sortReferenceValues(attribute.getValue(), PropertyType.WEAKREFERENCE));
396 }
397 if (!excluded) {
398 collectNamespacePrefix(namespacePrefixesActuallyUsed, attribute.getNamespacePrefix());
399 }
400 }
401
402
403 if (setReplicationAttributes && matches(path, markReplicationActivatedIncludeNodes, true)) {
404 addMixin(element, "cq:ReplicationStatus");
405 element.setAttribute("lastReplicated", "{Date}" + dateLastReplicated, CQ_NAMESPACE);
406 element.setAttribute("lastReplicationAction", "Activate", CQ_NAMESPACE);
407 collectNamespacePrefix(namespacePrefixesActuallyUsed, CQ_NAMESPACE.getPrefix());
408 }
409
410
411 if (isReplicationElement && element.getChild("content", JCR_NAMESPACE) == null
412 && matches(path + "/jcr:content", markReplicationActivatedIncludeNodes, true)) {
413 Element contentNode = new Element("content", JCR_NAMESPACE);
414 String jcrContentPrimaryType = Strings.CS.equals(jcrPrimaryType, "cq:Template") ? "cq:PageContent" : jcrPrimaryType + "Content";
415 contentNode.setAttribute("primaryType", jcrContentPrimaryType, JCR_NAMESPACE);
416 element.addContent(contentNode);
417 }
418
419 List<Element> children = new ArrayList<>(element.getChildren());
420 for (Element child : children) {
421 applyXmlExcludes(child, path, namespacePrefixesActuallyUsed, (insideReplicationElement || isReplicationElement) && !isContent);
422 }
423 }
424
425 private String filterMixinsPropertyValue(String value, Set<String> namespacePrefixesActuallyUsed) {
426 if (this.excludeMixins.length == 0 || StringUtils.isBlank(value)) {
427 return value;
428 }
429
430 List<String> mixins = new ArrayList<>();
431 for (String mixin : DocViewUtil.parseValues(value)) {
432 if (!matches(mixin, this.excludeMixins, false)) {
433 String namespacePrefix = StringUtils.substringBefore(mixin, ":");
434 collectNamespacePrefix(namespacePrefixesActuallyUsed, namespacePrefix);
435 mixins.add(mixin);
436 }
437 }
438
439 if (mixins.isEmpty()) {
440 return null;
441 }
442
443 return DocViewUtil.formatValues(mixins);
444 }
445
446 private void addMixin(Element element, String mixin) {
447 String mixinsString = element.getAttributeValue("mixinTypes", JCR_NAMESPACE);
448
449 List<String> mixins = new ArrayList<>();
450 if (!StringUtils.isBlank(mixinsString)) {
451 for (String item : DocViewUtil.parseValues(mixinsString)) {
452 mixins.add(item);
453 }
454 }
455 if (!mixins.contains(mixin)) {
456 mixins.add(mixin);
457 }
458
459 element.setAttribute("mixinTypes", DocViewUtil.formatValues(mixins), JCR_NAMESPACE);
460 }
461
462 private void collectNamespacePrefix(Set<String> prefixes, String prefix) {
463 if (StringUtils.isNotBlank(prefix)) {
464 prefixes.add(prefix);
465 }
466 }
467
468 private void collectNamespacePrefixNameArray(Set<String> prefixes, String value) {
469 for (String item : DocViewUtil.parseValues(value)) {
470 String namespacePrefix = StringUtils.substringBefore(item, ":");
471 collectNamespacePrefix(prefixes, namespacePrefix);
472 }
473 }
474
475
476
477
478
479
480
481 private String sortReferenceValues(String value, int propertyType) {
482 Set<String> refs = new TreeSet<>();
483 for (String item : DocViewUtil.parseValues(value)) {
484 refs.add(item);
485 }
486 return DocViewUtil.formatValues(new ArrayList<>(refs), propertyType);
487 }
488
489 }