View Javadoc
1   /**
2    * This file Copyright (c) 2003-2015 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.cms.core.version;
35  
36  import info.magnolia.cms.core.Content;
37  import info.magnolia.cms.core.Path;
38  import info.magnolia.cms.security.JCRSessionOp;
39  import info.magnolia.cms.security.PermissionUtil;
40  import info.magnolia.cms.util.Rule;
41  import info.magnolia.context.MgnlContext;
42  import info.magnolia.context.SystemContext;
43  import info.magnolia.jcr.predicate.RuleBasedNodePredicate;
44  import info.magnolia.jcr.util.NodeTypes;
45  import info.magnolia.jcr.util.NodeUtil;
46  import info.magnolia.jcr.util.VersionUtil;
47  import info.magnolia.repository.RepositoryConstants;
48  import info.magnolia.repository.RepositoryManager;
49  import info.magnolia.repository.definition.RepositoryDefinition;
50  
51  import java.io.ByteArrayInputStream;
52  import java.io.ByteArrayOutputStream;
53  import java.io.IOException;
54  import java.io.InvalidClassException;
55  import java.io.ObjectInput;
56  import java.io.ObjectInputStream;
57  import java.io.ObjectOutput;
58  import java.io.ObjectOutputStream;
59  import java.util.ArrayList;
60  import java.util.Collection;
61  import java.util.List;
62  
63  import javax.inject.Inject;
64  import javax.jcr.ItemNotFoundException;
65  import javax.jcr.Node;
66  import javax.jcr.NodeIterator;
67  import javax.jcr.PathNotFoundException;
68  import javax.jcr.RepositoryException;
69  import javax.jcr.Session;
70  import javax.jcr.UnsupportedRepositoryOperationException;
71  import javax.jcr.Value;
72  import javax.jcr.nodetype.NodeType;
73  import javax.jcr.version.Version;
74  import javax.jcr.version.VersionException;
75  import javax.jcr.version.VersionHistory;
76  import javax.jcr.version.VersionIterator;
77  import javax.jcr.version.VersionManager;
78  
79  import org.apache.commons.codec.binary.Base64;
80  import org.apache.commons.io.IOUtils;
81  import org.apache.jackrabbit.JcrConstants;
82  import org.slf4j.Logger;
83  import org.slf4j.LoggerFactory;
84  
85  /**
86   * This version manager uses an extra workspace to manage the versions. The
87   * workspace maintains a flat hierarchy. The content is then finally versioned
88   * using JCR versioning which also copies the sub-nodes.
89   *
90   * The mix:versionable is only added on the top level nodes.
91   */
92  public abstract class BaseVersionManager {
93  
94      private static final Logger log = LoggerFactory.getLogger(BaseVersionManager.class);
95  
96      /**
97       * Name of the workspace.
98       */
99      public static final String VERSION_WORKSPACE = "mgnlVersion";
100 
101     /**
102      * Node which contains stubs for referenced nodes. We have to copy them to the workspace as well.
103      */
104     public static final String TMP_REFERENCED_NODES = "mgnl:tmpReferencedNodes";
105 
106     /**
107      * Sub-node containing the data used for the version/restore process.
108      */
109     protected static final String SYSTEM_NODE = "mgnl:versionMetaData";
110 
111     /**
112      * Property name for collection rule. The rule defines which sub-nodes belong to a node: page and paragraphs.
113      */
114     public static final String PROPERTY_RULE = "Rule";
115 
116     /**
117      * JCR version store root.
118      */
119     protected static final String ROOT_VERSION = "jcr:rootVersion";
120 
121     private Collection<String> versionWorkspaces = new ArrayList<>();
122 
123     private final SystemContext systemContext;
124     private final RepositoryManager repositoryManager;
125     private final CopyUtil copyUtil;
126 
127     @Inject
128     public BaseVersionManager(SystemContext systemContext, RepositoryManager repositoryManager, CopyUtil copyUtil) {
129         this.systemContext = systemContext;
130         this.repositoryManager = repositoryManager;
131         this.copyUtil = copyUtil;
132         for (RepositoryDefinition repositoryDefinition : repositoryManager.getRepositoryDefinitions()) {
133             String repositoryId = repositoryDefinition.getName();
134             String workspaceName = repositoryId + "-" + RepositoryConstants.VERSION_STORE;
135             if (repositoryManager.getWorkspaceMapping(workspaceName) != null) {
136                 versionWorkspaces.add(workspaceName);
137             } else {
138                 throw new RuntimeException(String.format("Something went wrong, version workspace for repository %s does not exist.", repositoryId));
139             }
140         }
141     }
142 
143     /**
144      * Create structure needed for version store workspace.
145      *
146      * @throws RepositoryException if unable to create magnolia system structure
147      */
148     protected void createInitialStructure() throws RepositoryException {
149         for (String workspaceName : versionWorkspaces) {
150             MgnlContext.doInSystemContext(new JCRSessionOp<Void>(workspaceName) {
151 
152                 @Override
153                 public Void exec(Session session) throws RepositoryException {
154                     try {
155                         Node tmp = session.getNode("/" + TMP_REFERENCED_NODES);
156                         // remove nodes if they are no longer referenced within this workspace
157                         NodeIterator children = tmp.getNodes();
158                         while (children.hasNext()) {
159                             Node child = children.nextNode();
160                             if (child.getReferences().getSize() < 1) {
161                                 child.remove();
162                             }
163                         }
164                     } catch (PathNotFoundException e) {
165                         session.getRootNode().addNode(TMP_REFERENCED_NODES, NodeTypes.System.NAME);
166                     }
167                     session.save();
168 
169                     return null;
170                 }
171             });
172         }
173     }
174 
175     /**
176      * Add version of the specified node and all child nodes while ignoring the same node type.
177      */
178     public synchronized Version addVersion(Node node) throws RepositoryException {
179         Rule rule = new Rule(VersionUtil.getNodeTypeName(node) + "," + NodeTypes.System.NAME, ",");
180         rule.reverse();
181         return this.addVersion(node, rule);
182     }
183 
184     /**
185      * Add version of the specified node and all child nodes based on the given <code>Rule</code>. Reads the <code>userName</code>
186      * from the current context.
187      */
188     public synchronized Version addVersion(final Node node, final Rule rule) throws RepositoryException {
189         final String userName = getSafelyUserNameFromMgnlContext();
190         return addVersion(node, rule, userName);
191     }
192 
193     /**
194      * Add version of the specified node and all child nodes based on the given <code>Rule</code> and <code>userName</code>.
195      */
196     public synchronized Version addVersion(final Node node, final Rule rule, final String userName) throws RepositoryException {
197         final String workspaceName = node.getSession().getWorkspace().getName();
198         Version version = MgnlContext.doInSystemContext(new JCRSessionOp<Version>(workspaceName) {
199 
200             @Override
201             public Version exec(Session session) throws RepositoryException {
202                 try {
203                     return createVersion(session.getNodeByIdentifier(node.getIdentifier()), rule, userName);
204                 } catch (RepositoryException re) {
205                     // since add version is synchronized on a singleton object, its safe to revert all changes made in
206                     // the session attached to workspace - mgnlVersion
207                     log.error("failed to copy versionable node to version store, reverting all changes made in this session", re);
208                     String versionWorkspaceName = VersionUtil.getVersionWorkspaceForNode(repositoryManager, node);
209                     // We're in SystemContext
210                     MgnlContext.getJCRSession(versionWorkspaceName).refresh(false);
211                     throw re;
212                 }
213 
214             }
215         });
216         return version;
217     }
218 
219     /**
220      * Create version of the specified node and all child nodes based on the given <code>Rule</code>.
221      *
222      * @param node to be versioned
223      * @return newly created version node
224      * @throws UnsupportedOperationException if repository implementation does not support Versions API
225      * @throws javax.jcr.RepositoryException if any repository error occurs
226      * @deprecated since 4.5 use {@link #createVersion(Node, Rule, String)} instead
227      */
228     @Deprecated
229     protected Version createVersion(Content node, Rule rule) throws UnsupportedRepositoryOperationException, RepositoryException {
230         return createVersion(node.getJCRNode(), rule, getSafelyUserNameFromMgnlContext());
231     }
232 
233     private String getSafelyUserNameFromMgnlContext() {
234         String userName = "";
235         if (MgnlContext.getUser() != null) {
236             userName = MgnlContext.getUser().getName();
237         }
238         return userName;
239     }
240 
241     /**
242      * Create version of the specified node and all child nodes based on the given <code>Rule</code>.
243      * This will return null if can't create new version in case when max version index is lower then one
244      * or when content is marked for deletion.
245      *
246      * @param node to be versioned
247      * @return newly created version node
248      * @throws UnsupportedOperationException if repository implementation does not support Versions API
249      * @throws javax.jcr.RepositoryException if any repository error occurs
250      */
251     protected Version createVersion(Node node, Rule rule, String userName) throws UnsupportedRepositoryOperationException,
252             RepositoryException {
253         if (isInvalidMaxVersions()) {
254             log.debug("Ignore create version, MaxVersionIndex < 1");
255             return null;
256         }
257         if (node.isNodeType(NodeTypes.Deleted.NAME)) {
258             log.debug("Don't create version for content marked as deleted");
259             return null;
260         }
261 
262         copyUtil.copyToVersion(node, new RuleBasedNodePredicate(rule));
263         Node versionedNode = this.getVersionedNode(node);
264 
265         checkAndAddMixin(versionedNode);
266         Node systemInfo = this.getSystemNode(versionedNode);
267         // add serialized rule which was used to create this version
268         ByteArrayOutputStream out = new ByteArrayOutputStream();
269         try {
270             ObjectOutput objectOut = new ObjectOutputStream(out);
271             objectOut.writeObject(rule);
272             objectOut.flush();
273             objectOut.close();
274             // PROPERTY_RULE is not a part of MetaData to allow versioning of node types which does NOT support MetaData
275             systemInfo.setProperty(PROPERTY_RULE, new String(Base64.encodeBase64(out.toByteArray())));
276         } catch (IOException e) {
277             throw new RepositoryException("Unable to add serialized Rule to the versioned content");
278         }
279         // add all system properties for this version
280         systemInfo.setProperty(ContentVersion.VERSION_USER, userName);
281         systemInfo.setProperty(ContentVersion.NAME, node.getName());
282 
283         versionedNode.save();
284 
285         // add version
286         VersionManager versionManager = versionedNode.getSession().getWorkspace().getVersionManager();
287         Version newVersion = versionManager.checkin(versionedNode.getPath());
288         versionManager.checkout(versionedNode.getPath());
289 
290         try {
291             this.setMaxVersionHistory(versionedNode);
292         } catch (RepositoryException re) {
293             log.error("Failed to limit version history to the maximum configured", re);
294             log.error("New version has already been created");
295         }
296 
297 
298         return newVersion;
299     }
300 
301     /**
302      * Check if max version index is lower then one.
303      */
304     public abstract boolean isInvalidMaxVersions();
305 
306     /**
307      * Get node from version store.
308      */
309     public synchronized Node getVersionedNode(Node node) throws RepositoryException {
310         try {
311             final String versionWorkspace = VersionUtil.getVersionWorkspaceForNode(repositoryManager, node);
312             final Session versionSession = systemContext.getJCRSession(versionWorkspace);
313             return versionSession.getNodeByIdentifier(node.getUUID());
314         } catch (ItemNotFoundException e) {
315             // node is not versioned yet
316             return null;
317         }
318     }
319 
320     /**
321      * Get node from version store.
322      */
323     protected Node getVersionedNode(Session session, String uuid) throws RepositoryException {
324         try {
325             return session.getNodeByIdentifier(uuid);
326         } catch (ItemNotFoundException e) {
327             // node is not versioned yet
328             return null;
329         }
330     }
331 
332     /**
333      * Set version history to max version possible.
334      *
335      * @throws RepositoryException if failed to get VersionHistory or fail to remove
336      */
337     public abstract void setMaxVersionHistory(Node node) throws RepositoryException;
338 
339     /**
340      * Get history of this node as recorded in the version store.
341      *
342      * @return version history of the given node
343      * @throws UnsupportedOperationException if repository implementation does not support Versions API
344      * @throws javax.jcr.RepositoryException if any repository error occurs
345      */
346     public synchronized VersionHistory getVersionHistory(Node node) throws UnsupportedRepositoryOperationException,
347             RepositoryException {
348         try {
349             Node versionedNode = this.getVersionedNode(node);
350             if (versionedNode == null) {
351                 // node does not exist in version store so no version history
352                 log.debug("No VersionHistory found for {} node.", node);
353                 return null;
354             }
355             return versionedNode.getVersionHistory();
356         } catch (UnsupportedRepositoryOperationException e) {
357             log.debug("Node {} is not versionable.", node);
358             // node is not versionable or underlying repo doesn't support versioning.
359             return null;
360         }
361     }
362 
363     /**
364      * Get named version.
365      *
366      * @throws UnsupportedOperationException if repository implementation does not support Versions API
367      * @throws javax.jcr.RepositoryException if any repository error occurs
368      */
369     public synchronized Version getVersion(Node node, String name) throws UnsupportedRepositoryOperationException,
370             RepositoryException {
371         VersionHistory history = this.getVersionHistory(node);
372         if (history != null) {
373             return new VersionedNode(history.getVersion(name), node);
374         }
375         log.error("Node {} was never versioned", node.getPath());
376         return null;
377     }
378 
379     /**
380      * Returns the current base version of given node.
381      */
382     public Version getBaseVersion(Node node) throws UnsupportedOperationException, RepositoryException {
383         Node versionedNode = this.getVersionedNode(node);
384         if (versionedNode != null) {
385             VersionManager versionManager = versionedNode.getSession().getWorkspace().getVersionManager();
386             return versionManager.getBaseVersion(versionedNode.getPath());
387         }
388 
389         throw new RepositoryException("Node " + node.getPath() + " was never versioned");
390     }
391 
392     /**
393      * Get all versions.
394      *
395      * @return Version iterator retrieved from version history
396      * @throws UnsupportedOperationException if repository implementation does not support Versions API
397      * @throws javax.jcr.RepositoryException if any repository error occurs
398      */
399     public synchronized VersionIterator getAllVersions(Node node) throws UnsupportedRepositoryOperationException, RepositoryException {
400         Node versionedNode = this.getVersionedNode(node);
401         if (versionedNode == null) {
402             // node does not exist in version store so no versions
403             return null;
404         }
405         VersionManager versionManager = versionedNode.getSession().getWorkspace().getVersionManager();
406         return versionManager.getVersionHistory(versionedNode.getPath()).getAllVersions();
407     }
408 
409     /**
410      * Restore specified version.
411      *
412      * @param node to be restored
413      * @param version to be used
414      * @throws javax.jcr.version.VersionException if the specified <code>versionName</code> does not exist in this
415      * node's version history
416      * @throws javax.jcr.RepositoryException if an error occurs
417      * @deprecated since 4.5 use {@link #restore(Node, Version, boolean)} instead
418      */
419     @Deprecated
420     public synchronized void restore(Content node, Version version, boolean removeExisting) throws VersionException, UnsupportedRepositoryOperationException, RepositoryException {
421         restore(node.getJCRNode(), version, removeExisting);
422     }
423 
424     /**
425      * Restore specified version.
426      *
427      * @param node to be restored
428      * @param version to be used
429      * @throws javax.jcr.version.VersionException if the specified <code>versionName</code> does not exist in this
430      * node's version history
431      * @throws javax.jcr.RepositoryException if an error occurs
432      */
433     public synchronized void restore(final Node node, Version version, boolean removeExisting) throws VersionException, UnsupportedRepositoryOperationException, RepositoryException {
434         // FYI: restore is done in SC!!! Check permissions manually
435         PermissionUtil.verifyIsGrantedOrThrowException(node.getSession(), node.getPath(), Session.ACTION_SET_PROPERTY + "," + Session.ACTION_REMOVE + "," + Session.ACTION_ADD_NODE);
436 
437         // get the cloned node from version store
438         final Node versionedNode = this.getVersionedNode(node);
439 
440         final Version unwrappedVersion;
441         if (version instanceof VersionedNode) {
442             unwrappedVersion = VersionUtil.unwrap(((VersionedNode) version).unwrap());
443         } else {
444             unwrappedVersion = VersionUtil.unwrap(version);
445         }
446 
447         VersionManager versionManager = versionedNode.getSession().getWorkspace().getVersionManager();
448         versionManager.restore(unwrappedVersion, removeExisting);
449         versionManager.checkout(versionedNode.getPath());
450         MgnlContext.doInSystemContext(new JCRSessionOp<Void>(versionedNode.getSession().getWorkspace().getName()) {
451 
452             @Override
453             public Void exec(Session session) throws RepositoryException {
454                 //mixins are NOT restored automatically
455                 List<String> mixins = new ArrayList<String>();
456                 for (Value v : unwrappedVersion.getNode(JcrConstants.JCR_FROZENNODE).getProperty("jcr:frozenMixinTypes").getValues()) {
457                     mixins.add(v.getString());
458                 }
459 
460                 final Node systemVersionedNode = session.getNodeByIdentifier(versionedNode.getUUID());
461                 for (NodeType nt : versionedNode.getMixinNodeTypes()) {
462                     if (!mixins.remove(nt.getName())) {
463                         systemVersionedNode.removeMixin(nt.getName());
464                     }
465                 }
466                 for (String mix : mixins) {
467                     systemVersionedNode.addMixin(mix);
468                 }
469                 systemVersionedNode.save();
470 
471                 try {
472                     // using system context here is forced by the fact that JR will use same context to also check source (mgnlVersion) repo READ permission and ordinary user has no rights in this workspace
473                     Session sysDestinationSession = MgnlContext.getJCRSession(node.getSession().getWorkspace().getName());
474                     log.debug("restoring info:{}:{}", sysDestinationSession.getWorkspace().getName(), node.getPath());
475                     Node destinationNode = sysDestinationSession.getNode(node.getPath());
476                     // if restored, update original node with the restored node and its subtree
477                     Rule rule = getUsedFilter(versionedNode);
478                     try {
479                         copyUtil.copyFromVersion(versionedNode, destinationNode, new RuleBasedNodePredicate(rule));
480                         if (NodeUtil.hasMixin(destinationNode, NodeTypes.Deleted.NAME)) {
481                             destinationNode.removeMixin(NodeTypes.Deleted.NAME);
482                         }
483                         // next line is required because restore is done via clone op that preserves last modification date otherwise.
484                         NodeTypes.LastModified.update(destinationNode);
485                         destinationNode.save();
486                         // node was updated in system context and we should make sure it is notified of the changes
487                         node.refresh(false);
488                     } catch (RepositoryException re) {
489                         log.debug("error during restore: {}", re.getMessage(), re);
490                         log.error("failed to restore versioned node, reverting all changes make to this node");
491                         destinationNode.refresh(false);
492                         throw re;
493                     }
494                 } catch (IOException e) {
495                     throw new RepositoryException(e);
496                 } catch (ClassNotFoundException e) {
497                     throw new RepositoryException(e);
498                 }
499                 return null;
500             }
501         });
502     }
503 
504     /**
505      * Removes all versions of the node associated with given UUID.
506      *
507      * @throws RepositoryException if fails to remove versioned node from the version store
508      */
509     public synchronized void removeVersionHistory(final Node node) throws RepositoryException {
510         PermissionUtil.verifyIsGrantedOrThrowException(node.getSession(), Path.getAbsolutePath(node.getPath()), Session.ACTION_ADD_NODE + "," + Session.ACTION_REMOVE + "," + Session.ACTION_SET_PROPERTY);
511         final String uuid = node.getIdentifier();
512         String workspaceName = repositoryManager.getRepositoryNameForWorkspace(node.getSession().getWorkspace().getName()) + "-" + RepositoryConstants.VERSION_STORE;
513         MgnlContext.doInSystemContext(new JCRSessionOp<Void>(workspaceName) {
514 
515             @Override
516             public Void exec(Session session) throws RepositoryException {
517                 Node node = getVersionedNode(session, uuid);
518                 if (node != null) {
519                     if (node.getReferences().getSize() < 1) {
520                         // remove node from the version store only if its not referenced
521                         node.remove();
522                     } else { // remove all associated versions
523                         VersionManager versionManager = node.getSession().getWorkspace().getVersionManager();
524                         VersionHistory history = versionManager.getVersionHistory(node.getPath());
525                         VersionIterator versions = history.getAllVersions();
526                         if (versions != null) {
527                             // skip root version
528                             versions.nextVersion();
529                             while (versions.hasNext()) {
530                                 history.removeVersion(versions.nextVersion().getName());
531                             }
532                         }
533                     }
534                 }
535                 session.save();
536                 return null;
537             }
538         });
539     }
540 
541     /**
542      * Verifies the existence of the mix:versionable and adds it if not.
543      */
544     protected void checkAndAddMixin(Node node) throws RepositoryException {
545         if (!node.isNodeType("mix:versionable")) {
546             log.debug("Add mix:versionable");
547             node.addMixin("mix:versionable");
548         }
549     }
550 
551     /**
552      * Get Rule used for this version.
553      */
554     protected Rule getUsedFilter(Node versionedNode) throws IOException, ClassNotFoundException, RepositoryException {
555         // if restored, update original node with the restored node and its subtree
556         ByteArrayInputStream inStream = null;
557         try {
558             String ruleString = this.getSystemNode(versionedNode).getProperty(PROPERTY_RULE).getString();
559             inStream = new ByteArrayInputStream(Base64.decodeBase64(ruleString.getBytes()));
560             ObjectInput objectInput = new ObjectInputStream(inStream);
561             return (Rule) objectInput.readObject();
562         } catch (InvalidClassException e) {
563             log.debug(e.getMessage());
564             log.debug("Will return default rule -> all child nodes while ignoring versionedNode nodeType.");
565             Rule rule = new Rule(VersionUtil.getNodeTypeName(versionedNode) + "," + NodeTypes.System.NAME, ",");
566             rule.reverse();
567             return rule;
568         } catch (IOException e) {
569             throw e;
570         } catch (ClassNotFoundException e) {
571             throw e;
572         } finally {
573             IOUtils.closeQuietly(inStream);
574         }
575     }
576 
577     /**
578      * Get the Magnolia system node created under the given node.
579      *
580      * @throws RepositoryException if failed to create system node
581      */
582     protected synchronized Node getSystemNode(Node node) throws RepositoryException {
583         if (node.hasNode(SYSTEM_NODE)) {
584             return node.getNode(SYSTEM_NODE);
585         }
586         return node.addNode(SYSTEM_NODE, NodeTypes.System.NAME);
587     }
588 }