View Javadoc
1   /*
2    * Copyright 2024 Bloomreach B.V. (https://www.bloomreach.com)
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *         http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package org.onehippo.forge.content.exim.repository.jaxrs;
17  
18  import java.io.File;
19  import java.io.FileOutputStream;
20  import java.io.IOException;
21  import java.io.InputStream;
22  import java.io.OutputStream;
23  import java.io.PrintStream;
24  import java.security.Principal;
25  import java.util.Arrays;
26  import java.util.Base64;
27  import java.util.Collection;
28  import java.util.LinkedHashSet;
29  import java.util.List;
30  import java.util.Set;
31  
32  import javax.jcr.Credentials;
33  import javax.jcr.LoginException;
34  import javax.jcr.Node;
35  import javax.jcr.NodeIterator;
36  import javax.jcr.RepositoryException;
37  import javax.jcr.Session;
38  import javax.jcr.SimpleCredentials;
39  import javax.jcr.query.Query;
40  import javax.jcr.query.QueryResult;
41  import jakarta.servlet.http.HttpServletRequest;
42  import jakarta.ws.rs.core.SecurityContext;
43  
44  import org.apache.commons.collections4.CollectionUtils;
45  import org.apache.commons.io.IOUtils;
46  import org.apache.commons.lang3.StringUtils;
47  import org.apache.commons.lang3.math.NumberUtils;
48  import org.apache.commons.lang3.time.FastDateFormat;
49  import org.apache.commons.vfs2.FileObject;
50  import org.apache.cxf.jaxrs.ext.multipart.Attachment;
51  import org.apache.cxf.jaxrs.ext.multipart.ContentDisposition;
52  import org.onehippo.cms7.utilities.logging.PrintStreamLogger;
53  import org.onehippo.forge.content.exim.core.ContentMigrationRecord;
54  import org.onehippo.forge.content.exim.core.util.AntPathMatcher;
55  import org.onehippo.forge.content.exim.core.util.TeeLoggerWrapper;
56  import org.onehippo.forge.content.exim.repository.jaxrs.param.ExecutionParams;
57  import org.onehippo.forge.content.exim.repository.jaxrs.param.QueriesAndPaths;
58  import org.onehippo.forge.content.exim.repository.jaxrs.param.ResultItem;
59  import org.onehippo.forge.content.exim.repository.jaxrs.status.ProcessStatus;
60  import org.onehippo.forge.content.exim.repository.jaxrs.util.ServletRequestUtils;
61  import org.onehippo.forge.content.pojo.model.ContentNode;
62  import org.slf4j.Logger;
63  import org.slf4j.LoggerFactory;
64  
65  import com.fasterxml.jackson.core.JsonParser;
66  import com.fasterxml.jackson.core.JsonProcessingException;
67  import com.fasterxml.jackson.databind.ObjectMapper;
68  import com.fasterxml.jackson.databind.SerializationFeature;
69  
70  /**
71   * AbstractContentEximService.
72   */
73  public abstract class AbstractContentEximService {
74  
75      private static Logger log = LoggerFactory.getLogger(AbstractContentEximService.class);
76  
77      /**
78       * System session credentials.
79       */
80      protected static final Credentials SYSTEM_CREDENTIALS = new SimpleCredentials("system", new char[] {});
81  
82      /**
83       * Prefix of the temporary folder or files. e.g, temporary folder in zip content creation.
84       */
85      protected static final String TEMP_PREFIX = "_exim_";
86  
87      /**
88       * The whole execution log file entry name.
89       */
90      protected static final String EXIM_EXECUTION_LOG_REL_PATH = "EXIM-INF/execution.log";
91  
92      /**
93       * Zip Entry name of the summary log for binaries.
94       */
95      protected static final String EXIM_SUMMARY_BINARIES_LOG_REL_PATH = "EXIM-INF/summary-binaries.log";
96  
97      /**
98       * Zip Entry name of the summary log for documents.
99       */
100     protected static final String EXIM_SUMMARY_DOCUMENTS_LOG_REL_PATH = "EXIM-INF/summary-documents.log";
101 
102     /**
103      * Zip Entry name prefix for the binary attachments.
104      */
105     protected static final String BINARY_ATTACHMENT_REL_PATH = "EXIM-INF/data/attachments";
106 
107     /**
108      * Stop signal file's relative path under the zip creating base folder.
109      * If this file is found in the process, the export or import process will stop right away.
110      */
111     protected static final String STOP_REQUEST_FILE_REL_PATH = "EXIM-INF/_stop_";
112 
113     private ProcessMonitor processMonitor;
114 
115     /**
116      * Jackson ObjectMapper instance.
117      */
118     private ObjectMapper objectMapper;
119 
120     /**
121      * JCR session given by the DaemonModule and used to create a new system session from it.
122      */
123     private Session daemonSession;
124 
125     /**
126      * Default constructor.
127      */
128     public AbstractContentEximService() {
129         objectMapper = new ObjectMapper();
130         objectMapper.configure(JsonParser.Feature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER, true);
131         objectMapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true);
132         objectMapper.configure(JsonParser.Feature.ALLOW_MISSING_VALUES, true);
133         objectMapper.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true);
134         objectMapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true);
135         objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
136     }
137 
138     protected void setProcessMonitor(ProcessMonitor processMonitor) {
139         this.processMonitor = processMonitor;
140     }
141 
142     protected ProcessMonitor getProcessMonitor() {
143         return processMonitor;
144     }
145 
146     /**
147      * Return the default Jackson ObjectMapper instance.
148      * @return ObjectMapper instance
149      */
150     protected ObjectMapper getObjectMapper() {
151         return objectMapper;
152     }
153 
154     /**
155      * Return the JCR session given by the DaemonModule.
156      * It is not supposed to use this JCR session directly to retrieve or manipulate content.
157      * You should use {@link #createSession()} for that purpose instead.
158      * @return the JCR session given by the DaemonModule
159      */
160     protected Session getDaemonSession() {
161         return daemonSession;
162     }
163 
164     /**
165      * Set the JCR session, supposed to be set by the DaemonModule.
166      * @param daemonSession the JCR session given by the DaemonModule
167      */
168     protected void setDaemonSession(Session daemonSession) {
169         this.daemonSession = daemonSession;
170     }
171 
172     /**
173      * Create a new JCR system session by impersonating the JCR session returned from {@link #getDaemonSession()}.
174      * @return a new JCR system session by impersonating the JCR session returned from {@link #getDaemonSession()}
175      * @throws LoginException if impersonation with system credentials fails
176      * @throws RepositoryException if repository exception occurs
177      */
178     protected Session createSession() throws LoginException, RepositoryException {
179         return getDaemonSession().impersonate(SYSTEM_CREDENTIALS);
180     }
181 
182     /**
183      * Return true if a stop signal file is found under the base folder.
184      * @param baseFolder the base folder where zip content files are created temporarily.
185      * @return true if a stop signal file is found under the base folder
186      */
187     protected boolean isStopRequested(FileObject baseFolder) {
188         try {
189             FileObject stopSignalFile = baseFolder.resolveFile(STOP_REQUEST_FILE_REL_PATH);
190             return stopSignalFile.exists();
191         } catch (Exception e) {
192             log.error("Failed to check stop request file.", e);
193         }
194 
195         return false;
196     }
197 
198     /**
199      * Return a JSON string by stringifying the {@code object} with the Jackson ObjectMapper.
200      * @param object object to stringify
201      * @return a JSON string by stringifying the {@code object} with the Jackson ObjectMapper
202      * @throws JsonProcessingException if JSON stringifying fails
203      */
204     protected String toJsonString(Object object) throws JsonProcessingException {
205         return getObjectMapper().writeValueAsString(object);
206     }
207 
208     /**
209      * Read the CXF attachment JAX-RS argument and convert it to a string.
210      * @param attachment CXF attachment JAX-RS argument
211      * @param charsetName charset name used in encoding
212      * @return string converted from the CXF attachment JAX-RS argument
213      * @throws IOException if IO exception occurs
214      */
215     protected String attachmentToString(Attachment attachment, String charsetName) throws IOException {
216         InputStream input = null;
217 
218         try {
219             input = attachment.getObject(InputStream.class);
220             return IOUtils.toString(input, charsetName);
221         } finally {
222             IOUtils.closeQuietly(input);
223         }
224     }
225 
226     /**
227      * Transfer attachment content into the given {@code file}.
228      * @param attachment attachment
229      * @param file destination file
230      * @throws IOException if IO exception occurs
231      */
232     protected void transferAttachmentToFile(Attachment attachment, File file) throws IOException {
233         InputStream input = null;
234         OutputStream output = null;
235 
236         try {
237             input = attachment.getObject(InputStream.class);
238             output = new FileOutputStream(file);
239             IOUtils.copyLarge(input, output);
240         } finally {
241             IOUtils.closeQuietly(output);
242             IOUtils.closeQuietly(input);
243         }
244     }
245 
246     /**
247      * Convert the {@link ContentMigrationRecord} instance to a {@link ResultItem} instance.
248      * @param record a {@link ContentMigrationRecord} instance
249      * @return a converted {@link ResultItem} instance
250      */
251     protected ResultItem recordToResultItem(ContentMigrationRecord record) {
252         ResultItem item = new ResultItem(record.getContentPath(), record.getContentType());
253         item.setSucceeded(record.isSucceeded());
254         item.setErrorMessage(record.getErrorMessage());
255         return item;
256     }
257 
258     /**
259      * Executes JCR query using the query {@code statement} in the query {@code language} and collect all the result
260      * node paths in a set to return.
261      * @param session JCR session
262      * @param statement JCR query statement
263      * @param language JCR query language
264      * @return a set containing all the nodes from the query result
265      * @throws RepositoryException if repository exception occurs
266      */
267     protected Set<String> getQueriedNodePaths(Session session, String statement, String language)
268             throws RepositoryException {
269         Set<String> nodePaths = new LinkedHashSet<>();
270         Query query = session.getWorkspace().getQueryManager().createQuery(statement, language);
271         QueryResult result = query.execute();
272 
273         for (NodeIterator nodeIt = result.getNodes(); nodeIt.hasNext();) {
274             Node node = nodeIt.nextNode();
275 
276             if (node != null) {
277                 nodePaths.add(node.getPath());
278             }
279         }
280 
281         return nodePaths;
282     }
283 
284     /**
285      * Override {@code params} by the give request parameter values.
286      * @param params {@link ExecutionParams} instance
287      * @param batchSizeParam batch size request parameter value
288      * @param throttleParam throttle request parameter value
289      * @param publishOnImportParam publishOnImport request parameter value
290      * @param dataUrlSizeThresholdParam dataUrlSizeThreshold request parameter value
291      * @param docbasePropNamesParam docbasePropNames request parameter value
292      * @param documentTagsParam documentTags request parameter value
293      * @param binaryTagsParam binaryTags request parameter value
294      */
295     protected void overrideExecutionParamsByParameters(ExecutionParams params, String batchSizeParam,
296             String throttleParam, String publishOnImportParam, String dataUrlSizeThresholdParam,
297             String docbasePropNamesParam, String documentTagsParam, String binaryTagsParam) {
298         if (StringUtils.isNotBlank(batchSizeParam)) {
299             params.setBatchSize(NumberUtils.toInt(batchSizeParam, params.getBatchSize()));
300         }
301 
302         if (StringUtils.isNotBlank(throttleParam)) {
303             params.setThrottle(NumberUtils.toLong(throttleParam, params.getThrottle()));
304         }
305 
306         if (StringUtils.isNotBlank(publishOnImportParam)) {
307             params.setPublishOnImport(publishOnImportParam);
308         }
309 
310         if (StringUtils.isNotBlank(dataUrlSizeThresholdParam)) {
311             params.setDataUrlSizeThreshold(
312                     NumberUtils.toLong(dataUrlSizeThresholdParam, params.getDataUrlSizeThreshold()));
313         }
314 
315         if (StringUtils.isNotBlank(docbasePropNamesParam)) {
316             params.setDocbasePropNames(
317                     new LinkedHashSet<>(Arrays.asList(StringUtils.split(docbasePropNamesParam, ","))));
318         }
319 
320         if (StringUtils.isNotBlank(documentTagsParam)) {
321             params.setDocumentTags(new LinkedHashSet<>(Arrays.asList(StringUtils.split(documentTagsParam, ";"))));
322         }
323 
324         if (StringUtils.isNotBlank(binaryTagsParam)) {
325             params.setBinaryTags(new LinkedHashSet<>(Arrays.asList(StringUtils.split(binaryTagsParam, ";"))));
326         }
327     }
328 
329     /**
330      * Find the attachment in {@code attachments} list by the {@code contentId}.
331      * @param attachments attachment list
332      * @param contentId content Id
333      * @return the attachment in {@code attachments} list found by the {@code contentId}
334      */
335     protected Attachment getAttachmentByContentId(List<Attachment> attachments, String contentId) {
336         if (attachments == null || attachments.isEmpty()) {
337             return null;
338         }
339 
340         for (Attachment attachment : attachments) {
341             if (StringUtils.equals(contentId, attachment.getContentId())) {
342                 return attachment;
343             }
344             ContentDisposition contentDisposition = attachment.getContentDisposition();
345             if (contentDisposition != null && StringUtils.equals(contentId, contentDisposition.getParameter("name"))) {
346                 return attachment;
347             }
348         }
349 
350         return null;
351     }
352 
353     /**
354      * Apply tag field on the content node with give {@code tagInfos} list, each item of which should look like
355      * "myhippoproject:tags=a,b,c".
356      * @param contentNode content node
357      * @param tagInfos tag info line like "myhippoproject:tags=a,b,c"
358      * @return true if any tag field is added
359      */
360     protected boolean applyTagContentProperties(ContentNode contentNode, Set<String> tagInfos) {
361         if (CollectionUtils.isEmpty(tagInfos)) {
362             return false;
363         }
364 
365         boolean updated = false;
366 
367         for (String tagInfo : tagInfos) {
368             String name = StringUtils.substringBefore(tagInfo, "=");
369             String values = StringUtils.substringAfter(tagInfo, "=");
370 
371             if (StringUtils.isBlank(name) || StringUtils.isBlank(values)) {
372                 log.warn("Invalid content tag info: {}", tagInfo);
373                 continue;
374             }
375 
376             contentNode.setProperty(name, StringUtils.split(values, ","));
377             updated = true;
378         }
379 
380         return updated;
381     }
382 
383     /**
384      * Find user principal's name from {@code securityContext} or {@code request}.
385      * @param securityContext security context
386      * @param request servlet request
387      * @return user principal's name from {@code securityContext} or {@code request}
388      */
389     protected String getUserPrincipalName(SecurityContext securityContext, HttpServletRequest request) {
390         if (securityContext != null) {
391             Principal userPrincipal = securityContext.getUserPrincipal();
392             if (userPrincipal != null) {
393                 return userPrincipal.getName();
394             }
395         }
396 
397         if (request != null) {
398             Principal userPrincipal = request.getUserPrincipal();
399             if (userPrincipal != null) {
400                 return userPrincipal.getName();
401             }
402 
403             final String authHeader = request.getHeader("Authorization");
404 
405             if (StringUtils.isNotBlank(authHeader)) {
406                 if (StringUtils.startsWithIgnoreCase(authHeader, "Basic ")) {
407                     final String encoded = authHeader.substring(6).trim();
408                     final String decoded = new String(Base64.getDecoder().decode(encoded));
409                     return StringUtils.substringBefore(decoded, ":");
410                 }
411             }
412         }
413 
414         return null;
415     }
416 
417     /**
418      * Fill basic info from {@code securityContext} and {@code request} in {@code process}.
419      * @param process process
420      * @param securityContext security context
421      * @param request servlet request
422      */
423     protected void fillProcessStatusByRequestInfo(ProcessStatus process, SecurityContext securityContext,
424             HttpServletRequest request) {
425         process.setUsername(getUserPrincipalName(securityContext, request));
426         process.setClientInfo(ServletRequestUtils.getFarthestRemoteAddr(request));
427 
428         StringBuilder sbCommand = new StringBuilder(256).append(request.getMethod()).append(' ')
429                 .append(request.getRequestURI());
430         String queryString = request.getQueryString();
431         if (StringUtils.isNotBlank(queryString)) {
432             sbCommand.append('?').append(queryString);
433         }
434         process.setCommandInfo(sbCommand.toString());
435     }
436 
437     /**
438      * Create a tee-ing logger.
439      * @param mainLogger main logger
440      * @param secondOutput output for the second logger
441      * @return a tee-ing logger
442      */
443     protected Logger createTeeLogger(final Logger mainLogger, final PrintStream secondOutput) {
444         final Logger second = new TimestampPrintStreamLogger("exim", PrintStreamLogger.INFO_LEVEL, secondOutput);
445         return new TeeLoggerWrapper(mainLogger, second);
446     }
447 
448     /**
449      * Return true if the given {@code path} is included in the {@code param}'s binary path includes parameter.
450      * @param pathMatcher AntPathMatcher instance
451      * @param params Execution params
452      * @param path binary path
453      * @return true if the given {@code path} is included in the {@code param}'s binary path includes parameter
454      */
455     protected boolean isBinaryPathIncluded(final AntPathMatcher pathMatcher, final ExecutionParams params,
456             final String path) {
457         QueriesAndPaths queriesAndPaths = params.getBinaries();
458 
459         if (queriesAndPaths == null) {
460             return true;
461         }
462 
463         return isPathIncluded(pathMatcher, queriesAndPaths.getExcludes(), queriesAndPaths.getIncludes(), path);
464     }
465 
466     /**
467      * Return true if the given {@code path} is included in the {@code param}'s document path includes parameter.
468      * @param pathMatcher AntPathMatcher instance
469      * @param params Execution params
470      * @param path document path
471      * @return true if the given {@code path} is included in the {@code param}'s document path includes parameter
472      */
473     protected boolean isDocumentPathIncluded(final AntPathMatcher pathMatcher, final ExecutionParams params,
474             final String path) {
475         QueriesAndPaths queriesAndPaths = params.getDocuments();
476 
477         if (queriesAndPaths == null) {
478             return true;
479         }
480 
481         return isPathIncluded(pathMatcher, queriesAndPaths.getExcludes(), queriesAndPaths.getIncludes(), path);
482     }
483 
484     private boolean isPathIncluded(final AntPathMatcher pathMatcher, final Collection<String> excludes,
485             final Collection<String> includes, final String path) {
486         if (CollectionUtils.isNotEmpty(excludes)) {
487             for (String exclude : excludes) {
488                 if (pathMatcher.match(exclude, path)) {
489                     return false;
490                 }
491             }
492         }
493 
494         if (CollectionUtils.isNotEmpty(includes)) {
495             for (String include : includes) {
496                 if (pathMatcher.match(include, path)) {
497                     return true;
498                 }
499             }
500             return false;
501         } else {
502             return true;
503         }
504     }
505 
506     private static class TimestampPrintStreamLogger extends PrintStreamLogger {
507 
508         private static final FastDateFormat dateFormat = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss,SSS");
509 
510         public TimestampPrintStreamLogger(final String name, final int level, final PrintStream... out)
511                 throws IllegalArgumentException {
512             super(name, level, out);
513         }
514 
515         @Override
516         protected String getMessageString(final String level, final String message) {
517             final String ts = dateFormat.format(System.currentTimeMillis());
518             return level + " " + ts + " " + message;
519         }
520     }
521 }