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