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