001/* 002 * Copyright 2025 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.cms.trash.element; 017 018import java.time.ZonedDateTime; 019import java.util.Arrays; 020import java.util.HashMap; 021import java.util.HashSet; 022import java.util.Map; 023import java.util.Map.Entry; 024import java.util.Optional; 025import java.util.Set; 026import java.util.function.Function; 027import java.util.stream.Collectors; 028 029import javax.jcr.Repository; 030import javax.jcr.RepositoryException; 031import javax.jcr.Session; 032 033import org.apache.avalon.framework.component.Component; 034import org.apache.avalon.framework.service.ServiceException; 035import org.apache.avalon.framework.service.ServiceManager; 036import org.apache.avalon.framework.service.Serviceable; 037import org.apache.jackrabbit.util.ISO9075; 038 039import org.ametys.cms.ObservationConstants; 040import org.ametys.cms.trash.TrashConstants; 041import org.ametys.cms.trash.model.TrashElementModel; 042import org.ametys.core.observation.Event; 043import org.ametys.core.observation.ObservationManager; 044import org.ametys.core.user.CurrentUserProvider; 045import org.ametys.core.util.DateUtils; 046import org.ametys.plugins.repository.AmetysObject; 047import org.ametys.plugins.repository.AmetysObjectIterable; 048import org.ametys.plugins.repository.AmetysObjectResolver; 049import org.ametys.plugins.repository.AmetysRepositoryException; 050import org.ametys.plugins.repository.ModifiableTraversableAmetysObject; 051import org.ametys.plugins.repository.RemovableAmetysObject; 052import org.ametys.plugins.repository.collection.AmetysObjectCollection; 053import org.ametys.plugins.repository.collection.AmetysObjectCollectionFactory; 054import org.ametys.plugins.repository.jcr.JCRAmetysObject; 055import org.ametys.plugins.repository.jcr.NameHelper; 056import org.ametys.plugins.repository.jcr.NameHelper.NameComputationMode; 057import org.ametys.plugins.repository.provider.AbstractRepository; 058import org.ametys.plugins.repository.query.QueryHelper; 059import org.ametys.plugins.repository.query.expression.AndExpression; 060import org.ametys.plugins.repository.query.expression.BooleanExpression; 061import org.ametys.plugins.repository.query.expression.DateExpression; 062import org.ametys.plugins.repository.query.expression.Expression; 063import org.ametys.plugins.repository.query.expression.Expression.Operator; 064import org.ametys.plugins.repository.query.expression.StringExpression; 065import org.ametys.plugins.repository.trash.ConflictingNameException; 066import org.ametys.plugins.repository.trash.TrashElement; 067import org.ametys.plugins.repository.trash.TrashElementTypeExtensionPoint; 068import org.ametys.plugins.repository.trash.TrashableAmetysObject; 069import org.ametys.plugins.repository.trash.TrashableAmetysObject.MergeComputationMode; 070import org.ametys.plugins.repository.trash.UnknownParentException; 071import org.ametys.runtime.plugin.component.AbstractLogEnabled; 072 073/** 074 * {@link TrashElementDAO} to manage {@link TrashElement}s. 075 */ 076public class TrashElementDAO extends AbstractLogEnabled implements org.ametys.plugins.repository.trash.TrashElementDAO, Serviceable, Component 077{ 078 /** The name of the trash root node */ 079 protected static final String __TRASH_ROOT_NODE_NAME = "ametys-internal:trash"; 080 081 /** the Ametys object resolver */ 082 protected AmetysObjectResolver _resolver; 083 084 private Repository _repository; 085 private ObservationManager _observationManager; 086 private CurrentUserProvider _currentUserProvider; 087 088 private TrashElementTypeExtensionPoint _trashTypeEP; 089 090 public void service(ServiceManager serviceManager) throws ServiceException 091 { 092 _repository = (Repository) serviceManager.lookup(AbstractRepository.ROLE); 093 _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE); 094 _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE); 095 _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE); 096 _trashTypeEP = (TrashElementTypeExtensionPoint) serviceManager.lookup(TrashElementTypeExtensionPoint.ROLE); 097 } 098 099 /** 100 * Resolve the {@link AmetysObject} into the trash workspace. 101 * @param <A> The type of the returned {@link AmetysObject}. 102 * @param ametysObjectId The identifier 103 * @return the resolved Ametys object, can be null if not found 104 */ 105 public <A extends AmetysObject> A resolve(String ametysObjectId) 106 { 107 return executeInTrashSession(session -> _resolveSilently(ametysObjectId, session)); 108 } 109 110 private <A extends AmetysObject> A _resolveSilently(String ametysObjectId, Session session) 111 { 112 try 113 { 114 return _resolver.resolveById(ametysObjectId, session); 115 } 116 catch (RepositoryException e) 117 { 118 throw new AmetysRepositoryException(e); 119 } 120 } 121 122 /** 123 * Find a trash element linked to a trashed ametys object. 124 * @param ametysObjectId The ametys object id 125 * @return the {@link TrashElement}, can be null if not found 126 */ 127 public TrashElement find(String ametysObjectId) 128 { 129 return executeInTrashSession( 130 session -> 131 { 132 try 133 { 134 Expression expression = new StringExpression(TrashElementModel.DELETED_OBJECT, Operator.EQ, ametysObjectId); 135 String xPathQuery = QueryHelper.getXPathQuery(null, TrashElementFactory.TRASH_ELEMENT_NODETYPE, expression); 136 return _resolver.<TrashElement>query(xPathQuery, session).stream().findFirst().orElse(null); 137 } 138 catch (RepositoryException e) 139 { 140 throw new AmetysRepositoryException(e); 141 } 142 } 143 ); 144 } 145 146 /** 147 * Empty the trash. 148 * @param filter indicates how to filter element 149 * @throws AmetysRepositoryException if an error occurs 150 */ 151 public void empty(TrashElementFilter filter) throws AmetysRepositoryException 152 { 153 empty(Optional.empty(), filter.getMinimumAge()); 154 } 155 156 /** 157 * Remove trash elements. 158 * @param rootPath an optional JCR path to filter element to remove 159 * @param lifetime Number of days before the trash has to be emptied. 0 or less empty all elements. 160 */ 161 protected void empty(Optional<String> rootPath, long lifetime) 162 { 163 executeInTrashSession( 164 session -> 165 { 166 Expression expression = lifetime > 0 167 ? new AndExpression( 168 // Only delete visible trash elements. Hidden trash elements will be deleted by cascading if needed 169 new BooleanExpression(TrashElementModel.HIDDEN, false), 170 new DateExpression(TrashElementModel.DATE, Operator.LT, DateUtils.asDate(ZonedDateTime.now().minusDays(lifetime))) 171 ) 172 // If lifetime is not set, we want to delete all contents in the trash 173 : null; 174 175 String xPathQuery = QueryHelper.getXPathQuery(null, TrashElementFactory.TRASH_ELEMENT_NODETYPE, expression); 176 177 if (rootPath.isPresent()) 178 { 179 xPathQuery = rootPath.get() + xPathQuery; 180 } 181 try (AmetysObjectIterable<TrashElement> trashElements = _resolver.query(xPathQuery, session)) 182 { 183 for (TrashElement trashElement : trashElements) 184 { 185 _remove(trashElement, lifetime > 0); 186 } 187 } 188 catch (RepositoryException e) 189 { 190 throw new AmetysRepositoryException(e); 191 } 192 finally 193 { 194 session.logout(); 195 } 196 197 return null; 198 } 199 ); 200 } 201 202 /** 203 * Compute the JCR path of the object and encode it to be used in XPath query 204 * @param object the JCR Ametys object 205 * @return the path 206 * @throws RepositoryException if an error occurs 207 */ 208 protected String getEncodedJCRPath(JCRAmetysObject object) throws RepositoryException 209 { 210 String path = object.getNode().getPath(); 211 212 return "/jcr:root" + Arrays.stream(path.split("/")) 213 .map(ISO9075::encode) 214 .collect(Collectors.joining("/")); 215 } 216 217 /** 218 * Trash the {@link AmetysObject}. 219 * @param ametysObject The Ametys object 220 * @return the {@link TrashElement} created while moving the object to the trash 221 */ 222 public TrashElement trash(TrashableAmetysObject ametysObject) 223 { 224 return trash(ametysObject, false, null); 225 } 226 227 /** 228 * Trash the {@link AmetysObject}. 229 * @param ametysObject The Ametys object 230 * @param hidden <code>true</code> if it is an object trashed by another one 231 * @param linkedObjects The list of linked objects to the current object to trash. Linked objects can be also trashed, but it is not mandatory. 232 * @return the {@link TrashElement} created while moving the object to the trash 233 */ 234 public TrashElement trash(TrashableAmetysObject ametysObject, boolean hidden, String[] linkedObjects) 235 { 236 String ametysObjectId = ametysObject.getId(); 237 238 // Move the trashed node under the trash element 239 JCRAmetysObject originalParent = ametysObject.getParent(); 240 TrashElement trashElement = ametysObject.moveToTrash(); 241 trashElement.setHidden(hidden); 242 trashElement.addLinkedObjects(linkedObjects); 243 244 // Save changes to trash first. That way if the save fails, the original node is not destroyed 245 trashElement.saveChanges(); 246 originalParent.saveChanges(); 247 248 // Notify observers 249 Map<String, Object> eventParams = new HashMap<>(); 250 eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId()); 251 eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, ametysObjectId); 252 _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_ADDED, _currentUserProvider.getUser(), eventParams)); 253 254 return trashElement; 255 } 256 257 @Override 258 public TrashElement createTrashElement(TrashableAmetysObject ametysObject, String title) 259 { 260 ModifiableTraversableAmetysObject trash = getOrCreateRoot(ametysObject); 261 262 // Create the trash element 263 String nodeName = NameHelper.getUniqueAmetysObjectName(trash, "trash", NameComputationMode.GENERATED_KEY, false); 264 TrashElement trashElement = trash.createChild(nodeName, TrashElementFactory.TRASH_ELEMENT_NODETYPE); 265 trashElement.setValue(TrashElementModel.TITLE, title); 266 trashElement.setValue(TrashElementModel.AUTHOR, _currentUserProvider.getUser()); 267 trashElement.setValue(TrashElementModel.DATE, ZonedDateTime.now()); 268 trashElement.setValue(TrashElementModel.DELETED_OBJECT, ametysObject.getId()); 269 trashElement.setValue(TrashElementModel.PARENT_PATH, ametysObject.getParentPath()); 270 trashElement.setValue(TrashElementModel.HIDDEN, false); 271 _trashTypeEP.getFirstSupportingExtension(ametysObject) 272 .ifPresentOrElse( 273 type -> trashElement.setValue(TrashElementModel.TRASH_TYPE, type.getId()), 274 () -> { 275 // remove the node to prevent the presence of inconsistent data 276 trashElement.remove(); 277 throw new IllegalStateException("No supporting type for trashable '" + ametysObject.getId() + "'."); 278 } 279 ); 280 281 return trashElement; 282 } 283 284 /** 285 * Remove the {@link TrashElement} and its linked objects. 286 * @param trashElementId The trash element identifier to remove 287 */ 288 public void remove(String trashElementId) 289 { 290 _remove(resolve(trashElementId), true); 291 } 292 293 private void _remove(TrashElement trashElement, boolean removeLinkedElement) 294 { 295 // Get event param before deletion 296 Map<String, Object> eventParams = new HashMap<>(); 297 eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, trashElement.getId()); 298 eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, trashElement.getAmetysObjectId()); 299 300 if (removeLinkedElement) 301 { 302 // Remove linked objects 303 trashElement.getLinkedElements(true).forEach(e -> _remove(e, true)); 304 } 305 306 // Remove the trash element itself 307 _removeSingleObject(trashElement); 308 309 // Notify observers 310 _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_DELETED, _currentUserProvider.getUser(), eventParams)); 311 } 312 313 private void _removeSingleObject(RemovableAmetysObject ametysObject) 314 { 315 JCRAmetysObject parent = ametysObject.getParent(); 316 ametysObject.remove(); 317 parent.saveChanges(); 318 } 319 320 /** 321 * Restore the {@link TrashElement} and its linked objects. 322 * @param trashElementId The trash element identifier to restore 323 * @param mergeComputationMode the merge computation mode, used to know how to behave when restoring an existing element. May be null, as all implementations may not use it. 324 * @return a report of the operation 325 * @throws UnknownParentException if its not possible to obtains a parent to restore into 326 * @throws ConflictingNameException if there is a conflict between restored element or on of child element with existing element 327 */ 328 public RestorationReport restore(String trashElementId, MergeComputationMode mergeComputationMode) throws UnknownParentException, ConflictingNameException 329 { 330 TrashElement trashElement = resolve(trashElementId); 331 332 // Restore the object and all its linked objects 333 Map<String, TrashableAmetysObject> result = _restore(trashElement, mergeComputationMode); 334 335 // Perform additional action now that all object are restored 336 for (TrashableAmetysObject restoredObject : result.values()) 337 { 338 _trashTypeEP.getFirstSupportingExtension(restoredObject) 339 .ifPresent(type -> type.additionnalRestoreAction(restoredObject)); 340 } 341 342 // Then notify after all the restoration to unsure that all reference between restored elements are valid 343 Set<TrashableAmetysObject> restoredLinkedObject = new HashSet<>(); 344 for (Entry<String, TrashableAmetysObject> entry : result.entrySet()) 345 { 346 String elementId = entry.getKey(); 347 TrashableAmetysObject restoredObject = entry.getValue(); 348 349 // Notify observers 350 Map<String, Object> eventParams = new HashMap<>(); 351 eventParams.put(ObservationConstants.ARGS_TRASH_ELEMENT_ID, elementId); 352 eventParams.put(ObservationConstants.ARGS_AMETYS_OBJECT_ID, restoredObject.getId()); 353 _observationManager.notify(new Event(ObservationConstants.EVENT_TRASH_RESTORED, _currentUserProvider.getUser(), eventParams)); 354 355 // add to the linked object set if its not the original 356 if (!elementId.equals(trashElementId)) 357 { 358 restoredLinkedObject.add(restoredObject); 359 } 360 } 361 362 return new RestorationReport(result.get(trashElementId), restoredLinkedObject); 363 } 364 365 /** 366 * Restore a trash element, and its hidden linked element (if available) recursively 367 * @param trashElement the trash element to restore 368 * @return a map with the ids of the restored trash element as key and the restored trashables as value 369 * @throws ConflictingNameException if there is a conflict between restored element (or on of his children element) with existing element 370 */ 371 private Map<String, TrashableAmetysObject> _restore(TrashElement trashElement, MergeComputationMode mergeComputationMode) throws UnknownParentException, ConflictingNameException 372 { 373 Map<String, TrashableAmetysObject> result = new HashMap<>(); 374 375 String trashElementId = trashElement.getId(); 376 String trashableAOId = trashElement.getAmetysObjectId(); 377 TrashableAmetysObject trashableAO = resolve(trashableAOId); 378 379 // We first restored linked object because the page implementation has a physical reference to its contents. 380 // So if we restored the page before the contents, we have an exception from the repository. 381 // It seems to be unavoidable. 382 383 Set<TrashElement> linkedElements = trashElement.getLinkedElements(true); 384 385 Set<TrashElement> failedLinkedElements = new HashSet<>(); 386 387 for (TrashElement linkedElement: linkedElements) 388 { 389 try 390 { 391 result.putAll(_restore(linkedElement, mergeComputationMode)); 392 } 393 catch (UnknownParentException | ConflictingNameException e) 394 { 395 // Do not prevent restoring the original element because 396 // a linked element failed, however we want to store them in order to make them visible in trash if original element is restored 397 failedLinkedElements.add(linkedElement); 398 getLogger().warn("An error prevented to restore the element '{}' that is linked to the restoration of '{}'", linkedElement.getId(), trashableAO.getId()); 399 } 400 } 401 402 // Restore the current trash element 403 TrashableAmetysObject restoredAO = trashableAO.restoreFromTrash(mergeComputationMode); 404 405 result.put(trashElementId, restoredAO); 406 407 // If some linked element failed to be restored, we need to update them to be visible in the trash, as they are still linked to the original element that is now restored 408 failedLinkedElements.stream().forEach(failedLinkedElement -> { 409 try 410 { 411 failedLinkedElement.setHidden(false); 412 failedLinkedElement.saveChanges(); 413 } 414 catch (Exception e) 415 { 416 getLogger().error("Error while changing visibility of linked element '" + failedLinkedElement.getId() + "'.", e); 417 } 418 }); 419 420 // Remove the trash element 421 _removeSingleObject(trashElement); 422 423 return result; 424 } 425 426 /** 427 * Get or create the trash root 428 * @param ametysObject the object that need to be trashed 429 * @return the trash where the ametys will be trashed 430 */ 431 protected ModifiableTraversableAmetysObject getOrCreateRoot(TrashableAmetysObject ametysObject) 432 { 433 return executeInTrashSession( 434 session -> 435 { 436 ModifiableTraversableAmetysObject root = _resolver.resolveByPath("/", session); 437 return getOrCreateCollection(root, __TRASH_ROOT_NODE_NAME); 438 } 439 ); 440 } 441 442 /** 443 * Get or create an Ametys object collection on the given parent 444 * @param parent The parent 445 * @param collectionName The collection name 446 * @return The {@link AmetysObjectCollection} 447 */ 448 protected ModifiableTraversableAmetysObject getOrCreateCollection(ModifiableTraversableAmetysObject parent, String collectionName) 449 { 450 if (parent.hasChild(collectionName)) 451 { 452 return parent.getChild(collectionName); 453 } 454 else 455 { 456 ModifiableTraversableAmetysObject child = parent.createChild(collectionName, AmetysObjectCollectionFactory.COLLECTION_NODETYPE); 457 parent.saveChanges(); 458 return child; 459 } 460 } 461 462 463 /** 464 * Execute a function into the trash workspace. 465 * 466 * This method only handles the creation of the session. 467 * The caller is responsible for logging out (either in the function itself or by using the returned value). 468 * 469 * @param <A> The returned type, can be anything. If it is {@link Void}, the function should return <code>null</code> 470 * @param functionToExecute The function to execute, it takes a {@link Session} as parameter 471 * @return The result of the function 472 */ 473 protected <A> A executeInTrashSession(Function<Session, A> functionToExecute) 474 { 475 Session trashSession = null; 476 try 477 { 478 // Login the trash workspace 479 trashSession = _repository.login(TrashConstants.TRASH_WORKSPACE); 480 481 // Execute the function with the trash session 482 return functionToExecute.apply(trashSession); 483 } 484 catch (RepositoryException e) 485 { 486 throw new AmetysRepositoryException(e); 487 } 488 } 489 490 /** 491 * Describe the result of a restore operation. 492 * The report contains : 493 * <ul> 494 * <li>the restored object 495 * <li>a list of all the object restored with it 496 * </ul> 497 * @param restoredObject the restored object 498 * @param restoredLinkedObject the object restored along the {@code restoredObject} 499 */ 500 public record RestorationReport(TrashableAmetysObject restoredObject, Set<TrashableAmetysObject> restoredLinkedObject) { } 501 502 /** 503 * Information related to filtering trash element targeted by a mass operation 504 */ 505 public static class TrashElementFilter 506 { 507 private long _age; 508 509 /** 510 * Information related to filtering trash element targeted by a mass operation 511 * @param age Minimum age (in days) of the element. 0 or less means no filtering. 512 */ 513 public TrashElementFilter(long age) 514 { 515 _age = age; 516 } 517 518 /** 519 * Get the minimum age of an element 520 * @return the age in days 521 */ 522 public long getMinimumAge() 523 { 524 return _age; 525 } 526 } 527}