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.datapolicy; 017 018import java.time.Period; 019import java.time.ZonedDateTime; 020import java.util.HashMap; 021import java.util.Map; 022 023import javax.jcr.Node; 024import javax.jcr.NodeIterator; 025import javax.jcr.Repository; 026import javax.jcr.RepositoryException; 027import javax.jcr.Session; 028import javax.jcr.query.Query; 029import javax.jcr.query.QueryManager; 030import javax.jcr.query.QueryResult; 031 032import org.apache.avalon.framework.service.ServiceException; 033import org.apache.avalon.framework.service.ServiceManager; 034import org.apache.avalon.framework.service.Serviceable; 035import org.apache.commons.lang3.StringUtils; 036import org.apache.commons.lang3.tuple.Pair; 037 038import org.ametys.cms.ObservationConstants; 039import org.ametys.cms.repository.DefaultContent; 040import org.ametys.cms.repository.ModifiableContentHelper; 041import org.ametys.cms.repository.ReactionableObject.ReactionType; 042import org.ametys.cms.repository.ReactionableObjectHelper; 043import org.ametys.cms.repository.comment.AbstractComment; 044import org.ametys.cms.repository.comment.Comment; 045import org.ametys.cms.repository.comment.CommentableContent; 046import org.ametys.cms.repository.comment.contributor.ContributorCommentableContent; 047import org.ametys.core.observation.Event; 048import org.ametys.core.observation.ObservationManager; 049import org.ametys.core.trace.ForensicLogger; 050import org.ametys.core.user.CurrentUserProvider; 051import org.ametys.core.user.population.UserPopulationDAO; 052import org.ametys.core.user.status.PersonalDataPolicy; 053import org.ametys.core.user.status.PersonalDataProcessingException; 054import org.ametys.core.user.status.UserStatusInfo; 055import org.ametys.core.util.I18nUtils; 056import org.ametys.plugins.repository.AmetysObjectResolver; 057import org.ametys.plugins.repository.RepositoryConstants; 058import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder; 059import org.ametys.plugins.repository.data.holder.impl.DefaultModifiableModelLessDataHolder; 060import org.ametys.plugins.repository.data.repositorydata.impl.JCRRepositoryData; 061import org.ametys.plugins.repository.data.type.ModelItemTypeExtensionPoint; 062import org.ametys.plugins.repository.provider.AbstractRepository; 063import org.ametys.plugins.repository.query.expression.Expression.Operator; 064import org.ametys.plugins.repository.query.expression.StringExpression; 065import org.ametys.plugins.repository.query.expression.UserExpression; 066import org.ametys.runtime.config.Config; 067import org.ametys.runtime.i18n.I18nizableText; 068import org.ametys.runtime.plugin.component.AbstractLogEnabled; 069 070/** 071 * Data policy that anonymize comments and reaction made by an unknown user 072 */ 073public class ContentCommentAndReactionDataPolicy extends AbstractLogEnabled implements PersonalDataPolicy, Serviceable 074{ 075 /** The current user provider */ 076 protected CurrentUserProvider _currentUserProvider; 077 /** The modifiable content helper */ 078 protected ModifiableContentHelper _modifiableContentHelper; 079 /** The observation manager */ 080 protected ObservationManager _observationManager; 081 /** The repository provider */ 082 protected Repository _repository; 083 /** The Ametys object resolver */ 084 protected AmetysObjectResolver _resolver; 085 /** The i18n utils */ 086 protected I18nUtils _i18nUtils; 087 088 private ModelItemTypeExtensionPoint _unversionedDataTypeExtensionPoint; 089 private Period _retentionPeriod; 090 091 public void service(ServiceManager manager) throws ServiceException 092 { 093 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 094 _modifiableContentHelper = (ModifiableContentHelper) manager.lookup(ModifiableContentHelper.ROLE); 095 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 096 _repository = (Repository) manager.lookup(AbstractRepository.ROLE); 097 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 098 _unversionedDataTypeExtensionPoint = (ModelItemTypeExtensionPoint) manager.lookup(ModelItemTypeExtensionPoint.ROLE_UNVERSIONED); 099 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 100 101 Long config = Config.getInstance().<Long>getValue("cms.comment.data.policy.retention.period", false, null); 102 // disable the processing if config is blank 103 _retentionPeriod = config != null && config >= 0 ? Period.ofMonths(config.intValue()) : null; 104 } 105 106 public AnonymizationResult process(UserStatusInfo userStatusInfo) throws PersonalDataProcessingException 107 { 108 if (_retentionPeriod == null 109 || userStatusInfo.getMissingSinceDate().isAfter(ZonedDateTime.now().minus(_retentionPeriod))) 110 { 111 return AnonymizationResult.TOO_EARLY; 112 } 113 else 114 { 115 int handled = 0; 116 boolean error = false; 117 Session session = null; 118 try 119 { 120 session = _repository.login(); 121 QueryManager queryManager = session.getWorkspace().getQueryManager(); 122 HandlingResult result = _handleComments(userStatusInfo, queryManager); 123 handled += result.handled(); 124 error |= result.failed() > 0; 125 result = _handleContributorComments(userStatusInfo, queryManager); 126 handled += result.handled(); 127 error |= result.failed() > 0; 128 handled += _handleReactions(userStatusInfo, queryManager); 129 130 return error ? AnonymizationResult.ERROR : handled == 0 ? AnonymizationResult.NO_DATA : AnonymizationResult.PROCESSED; 131 } 132 catch (RepositoryException e) 133 { 134 throw new PersonalDataProcessingException("An error prevented the processing of comment from '" + userStatusInfo.getUserIdentity() + "'.", e); 135 } 136 finally 137 { 138 if (session != null) 139 { 140 session.logout(); 141 } 142 } 143 } 144 } 145 146 private HandlingResult _handleComments(UserStatusInfo userStatusInfo, QueryManager queryManager) throws RepositoryException 147 { 148 int handled = 0; 149 int failed = 0; 150 String email = userStatusInfo.getEmail(); 151 if (StringUtils.isNotBlank(email)) 152 { 153 // find any child node of an ametys:comments node inside the ametys-internal:unversioned node of a content 154 // with the author email equals to the email of the user we are processing 155 StringBuilder query = new StringBuilder("//element(*, ametys:content)") 156 .append("/ametys-internal:unversioned") 157 .append("//").append(RepositoryConstants.NAMESPACE_PREFIX).append(":").append(ModifiableContentHelper.METADATA_COMMENTS) 158 .append("/*[").append(new StringExpression(AbstractComment.METADATA_COMMENT_AUTHOREMAIL, Operator.EQ, email).build()).append("]"); 159 160 @SuppressWarnings("deprecation") 161 QueryResult result = queryManager.createQuery(query.toString(), Query.XPATH).execute(); 162 NodeIterator nodes = result.getNodes(); 163 while (nodes.hasNext()) 164 { 165 Node commentNode = nodes.nextNode(); 166 Pair<DefaultContent, String> contentAndCommentId = _modifiableContentHelper.getContentAndCommentId(commentNode); 167 if (contentAndCommentId != null) 168 { 169 DefaultContent content = contentAndCommentId.getLeft(); 170 Comment comment = ((CommentableContent) content).getComment(contentAndCommentId.getRight()); 171 172 comment.setAuthor(UserPopulationDAO.UNKNOWN_USER_IDENTITY); 173 comment.setAuthorName(_i18nUtils.translate(new I18nizableText("plugin.core", "PLUGINS_CORE_USERS_UNKNOWN_USER"), content.getLanguage())); 174 comment.setAuthorEmail(null); 175 comment.setEmailHiddenStatus(true); 176 comment.setAuthorURL(null); 177 comment.setEdited(true); 178 179 content.saveChanges(); 180 181 Map<String, Object> eventParams = new HashMap<>(); 182 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 183 eventParams.put(ObservationConstants.ARGS_COMMENT, comment); 184 eventParams.put("notify.users", false); 185 186 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_MODIFYING, _currentUserProvider.getUser(), eventParams)); 187 188 handled++; 189 } 190 else 191 { 192 failed++; 193 } 194 } 195 } 196 if (handled > 0 || failed > 0) 197 { 198 ForensicLogger.info("data.policy.gdpr.anonymize.content.comments", Map.of("handled", Long.toString(handled), "failed", Long.toString(failed), "identity", userStatusInfo.getUserIdentity()), UserPopulationDAO.SYSTEM_USER_IDENTITY); 199 } 200 return new HandlingResult(handled, failed); 201 } 202 203 private HandlingResult _handleContributorComments(UserStatusInfo userStatusInfo, QueryManager queryManager) throws RepositoryException 204 { 205 int handled = 0; 206 int failed = 0; 207 // find child of an ametys:contributor-comments node below the ametys-internal:unversioned node of a content 208 // with the user as author 209 StringBuilder query = new StringBuilder("//element(*, ametys:content)") 210 .append("/ametys-internal:unversioned") 211 .append("/").append(RepositoryConstants.NAMESPACE_PREFIX).append(":").append(ModifiableContentHelper.METADATA_CONTRIBUTOR_COMMENTS) 212 .append("/*[").append(new UserExpression(AbstractComment.METADATA_COMMENT_AUTHOR, Operator.EQ, userStatusInfo.getUserIdentity()).build()).append("]"); 213 214 @SuppressWarnings("deprecation") 215 QueryResult result = queryManager.createQuery(query.toString(), Query.XPATH).execute(); 216 NodeIterator nodes = result.getNodes(); 217 while (nodes.hasNext()) 218 { 219 Node commentNode = nodes.nextNode(); 220 Pair<DefaultContent, String> contentAndCommentId = _modifiableContentHelper.getContentAndCommentId(commentNode); 221 if (contentAndCommentId != null) 222 { 223 DefaultContent content = contentAndCommentId.getLeft(); 224 Comment comment = ((ContributorCommentableContent) content).getContributorComment(contentAndCommentId.getRight()); 225 comment.setAuthor(UserPopulationDAO.UNKNOWN_USER_IDENTITY); 226 comment.setEdited(true); 227 228 content.saveChanges(); 229 handled++; 230 } 231 else 232 { 233 failed++; 234 } 235 } 236 237 if (handled > 0 || failed > 0) 238 { 239 ForensicLogger.info("data.policy.gdpr.anonymize.content.contributor.comments", Map.of("handled", Long.toString(handled), "failed", Long.toString(failed), "identity", userStatusInfo.getUserIdentity()), UserPopulationDAO.SYSTEM_USER_IDENTITY); 240 } 241 return new HandlingResult(handled, failed); 242 } 243 244 private int _handleReactions(UserStatusInfo userStatusInfo, QueryManager queryManager) throws RepositoryException 245 { 246 int handled = 0; 247 for (ReactionType type : ReactionType.values()) 248 { 249 // find any node with the reaction type name below the ametys-internal:unversioned node of a content 250 // with the user as users 251 // this way, we retrieve reaction to content and content's comment at the same time 252 StringBuilder query = new StringBuilder("//element(*, ametys:content)") 253 .append("/ametys-internal:unversioned") 254 .append("//").append(RepositoryConstants.NAMESPACE_PREFIX).append(":").append(type.name().toLowerCase()) 255 .append("[").append(new UserExpression("users", Operator.EQ, userStatusInfo.getUserIdentity(), true).build()).append("]"); 256 257 @SuppressWarnings("deprecation") 258 QueryResult result = queryManager.createQuery(query.toString(), Query.XPATH).execute(); 259 NodeIterator nodes = result.getNodes(); 260 while (nodes.hasNext()) 261 { 262 // We get the parent node which is either the unversioned node or a commment 263 Node parentNode = nodes.nextNode().getParent(); 264 JCRRepositoryData repositoryData = new JCRRepositoryData(parentNode); 265 ModifiableModelLessDataHolder reactionableObject = new DefaultModifiableModelLessDataHolder(_unversionedDataTypeExtensionPoint, repositoryData); 266 ReactionableObjectHelper.removeReaction(reactionableObject, userStatusInfo.getUserIdentity(), type); 267 268 if (parentNode.getName().equals("ametys-internal:unversioned")) 269 { 270 // DefaultContent is the only implementation of a content that is reactionable 271 DefaultContent content = _resolver.resolve(parentNode.getParent(), false); 272 273 Map<String, Object> eventParams = new HashMap<>(); 274 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 275 eventParams.put(ObservationConstants.ARGS_REACTION_TYPE, type); 276 eventParams.put(ObservationConstants.ARGS_REACTION_ISSUER, _currentUserProvider.getUser()); 277 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_REACTION_CHANGED, _currentUserProvider.getUser(), eventParams)); 278 } 279 else 280 { 281 // Assume it isn't a contributor comment as reaction on contributor comment is not implemented 282 Pair<DefaultContent, String> contentAndCommentId = _modifiableContentHelper.getContentAndCommentId(parentNode); 283 if (contentAndCommentId != null) 284 { 285 DefaultContent content = contentAndCommentId.getLeft(); 286 Comment comment = ((CommentableContent) content).getComment(contentAndCommentId.getRight()); 287 288 Map<String, Object> eventParams = new HashMap<>(); 289 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 290 eventParams.put(ObservationConstants.ARGS_COMMENT, comment); 291 eventParams.put(ObservationConstants.ARGS_REACTION_TYPE, type); 292 eventParams.put(ObservationConstants.ARGS_REACTION_ISSUER, _currentUserProvider.getUser()); 293 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_COMMENT_REACTION_CHANGED, _currentUserProvider.getUser(), eventParams)); 294 } 295 } 296 297 parentNode.getSession().save(); 298 handled++; 299 } 300 301 } 302 303 if (handled > 0) 304 { 305 ForensicLogger.info("data.policy.gdpr.remove.content.reactions", Map.of("handled", Long.toString(handled), "identity", userStatusInfo.getUserIdentity()), UserPopulationDAO.SYSTEM_USER_IDENTITY); 306 } 307 return handled; 308 } 309 310 private static record HandlingResult(int handled, int failed) { } 311}