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}