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