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.plugins.workspaces.datapolicy;
017
018import java.time.Period;
019import java.time.ZonedDateTime;
020import java.util.Map;
021
022import javax.jcr.Node;
023import javax.jcr.NodeIterator;
024import javax.jcr.Repository;
025import javax.jcr.RepositoryException;
026import javax.jcr.Session;
027import javax.jcr.query.Query;
028import javax.jcr.query.QueryManager;
029import javax.jcr.query.QueryResult;
030
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034import org.apache.commons.lang3.StringUtils;
035import org.apache.commons.lang3.tuple.Pair;
036
037import org.ametys.cms.repository.ReactionableObject.ReactionType;
038import org.ametys.cms.repository.ReactionableObjectHelper;
039import org.ametys.cms.repository.comment.AbstractComment;
040import org.ametys.core.trace.ForensicLogger;
041import org.ametys.core.user.UserManager;
042import org.ametys.core.user.population.UserPopulationDAO;
043import org.ametys.core.user.status.PersonalDataPolicy;
044import org.ametys.core.user.status.PersonalDataProcessingException;
045import org.ametys.core.user.status.UserStatusInfo;
046import org.ametys.plugins.repository.AmetysObjectResolver;
047import org.ametys.plugins.repository.AmetysRepositoryException;
048import org.ametys.plugins.repository.RepositoryConstants;
049import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder;
050import org.ametys.plugins.repository.data.holder.impl.DefaultModifiableModelLessDataHolder;
051import org.ametys.plugins.repository.data.repositorydata.impl.JCRRepositoryData;
052import org.ametys.plugins.repository.data.type.ModelItemTypeExtensionPoint;
053import org.ametys.plugins.repository.provider.AbstractRepository;
054import org.ametys.plugins.repository.query.expression.Expression.Operator;
055import org.ametys.plugins.repository.query.expression.UserExpression;
056import org.ametys.runtime.config.Config;
057import org.ametys.runtime.plugin.component.AbstractLogEnabled;
058
059/**
060 * Abstract data policy for anonymization of comments and deletion reactions made by an unknown user in workspaces modules
061 */
062public abstract class AbstractCommentAndReactionDataPolicy extends AbstractLogEnabled implements PersonalDataPolicy, Serviceable
063{
064    /** The user manager */
065    protected UserManager _userManager;
066    /** The repository provider */
067    protected Repository _repository;
068    /** The Ametys object resolver */
069    protected AmetysObjectResolver _resolver;
070
071    private ModelItemTypeExtensionPoint _unversionedDataTypeExtensionPoint;
072    private Period _retentionPeriod;
073    
074    public void service(ServiceManager manager) throws ServiceException
075    {
076        _repository = (Repository) manager.lookup(AbstractRepository.ROLE);
077        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
078        _unversionedDataTypeExtensionPoint = (ModelItemTypeExtensionPoint) manager.lookup(ModelItemTypeExtensionPoint.ROLE_UNVERSIONED);
079        
080        Long config = Config.getInstance().<Long>getValue("cms.comment.data.policy.retention.period", false, null);
081        // disable the processing if config is blank
082        _retentionPeriod = config != null && config >= 0 ? Period.ofMonths(config.intValue()) : null;
083    }
084    
085    public AnonymizationResult process(UserStatusInfo userStatusInfo) throws PersonalDataProcessingException
086    {
087        if (_retentionPeriod == null
088            || userStatusInfo.getMissingSinceDate().isAfter(ZonedDateTime.now().minus(_retentionPeriod)))
089        {
090            return AnonymizationResult.TOO_EARLY;
091        }
092        else
093        {
094            int handled = 0;
095            boolean error = false;
096            Session session = null;
097            try
098            {
099                session = _repository.login();
100                QueryManager queryManager = session.getWorkspace().getQueryManager();
101                HandlingResult result = handleComments(userStatusInfo, queryManager);
102                handled += result.handled();
103                error |= result.failed() > 0;
104                handled += handleCommentReactions(userStatusInfo, queryManager);
105                
106                return error ? AnonymizationResult.ERROR : handled == 0 ? AnonymizationResult.NO_DATA : AnonymizationResult.PROCESSED;
107            }
108            catch (RepositoryException e)
109            {
110                throw new PersonalDataProcessingException("An error prevented the processing of comment from '" + userStatusInfo.getUserIdentity() + "'.", e);
111            }
112            finally
113            {
114                if (session != null)
115                {
116                    session.logout();
117                }
118            }
119        }
120    }
121    
122    /**
123     * Handle anonymization of comments
124     * @param userStatusInfo The user status infos
125     * @param queryManager the query manager
126     * @return the result
127     * @throws RepositoryException if an error occurred
128     */
129    protected HandlingResult handleComments(UserStatusInfo userStatusInfo, QueryManager queryManager) throws RepositoryException
130    {
131        int handled = 0;
132        int failed = 0;
133        
134        String query = getCommentsQuery(userStatusInfo);
135        
136        @SuppressWarnings("deprecation")
137        QueryResult result = queryManager.createQuery(query.toString(), Query.XPATH).execute();
138        NodeIterator nodes = result.getNodes();
139        while (nodes.hasNext())
140        {
141            Node commentNode = nodes.nextNode();
142            if (handleComment(commentNode))
143            {
144                handled++;
145            }
146            else
147            {
148                failed++;
149            }
150        }
151        
152        if (handled > 0 || failed > 0)
153        {
154            ForensicLogger.info("data.policy.gdpr.anonymize." + getLogCategory() + ".comments", Map.of("handled", Long.toString(handled), "failed", Long.toString(failed), "identity", userStatusInfo.getUserIdentity()), UserPopulationDAO.SYSTEM_USER_IDENTITY);
155        }
156        return new HandlingResult(handled, failed);
157    }
158    
159    /**
160     * Get the log category describing the object
161     * @return the log category
162     */
163    protected abstract String getLogCategory();
164
165    /**
166     * Get the query to retrieving comments
167     * @param userStatusInfo the user status info
168     * @return the JCR query
169     */
170    protected String getCommentsQuery(UserStatusInfo userStatusInfo)
171    {
172        StringBuilder query = new StringBuilder("//element(*, " + getObjectPrimaryType() + ")")
173                .append("//ametys:comments")
174                .append("/*[").append(new UserExpression(AbstractComment.METADATA_COMMENT_AUTHOR, Operator.EQ, userStatusInfo.getUserIdentity()).build()).append("]");
175        
176        return query.toString();
177    }
178    
179    /**
180     * Get the primary type of object that holds comments
181     * @return the primary type
182     */
183    protected abstract String getObjectPrimaryType();
184    
185    /**
186     * Handle anonymization of comment
187     * @param commentNode the comment node
188     * @return true if anomymization succeed, false otherwise
189     */
190    protected abstract boolean handleComment(Node commentNode);
191    
192    /**
193     * Get parent holder node and comment id from comment node
194     * @param commentNode the comment node
195     * @return the parent node and comment id or null if not found
196     */
197    protected Pair<Node, String> getObjectNodeAndCommentId(Node commentNode)
198    {
199        String commentNodePath = null;
200        try
201        {
202            commentNodePath = commentNode.getPath();
203            
204            // Retrieve the comment id by going up the hierarchy
205            // …/ametys-internal:resources/forums/thread-ID/ametys:comments/ametys:comment-x/ametys:comments/ametys:comment-y
206            // …/ametys-internal:resources/tasks/tasks-root/task-ID/ametys:comments/ametys:comments/ametys:comment-x/ametys:comments/ametys:comment-y
207            // => comment-x_comment-y
208            
209            // We start at the comment node
210            StringBuilder commentId = new StringBuilder(StringUtils.removeStart(commentNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":"));
211            
212            // Go up to parent object while reconstructing the ID of comment
213            Node parentNode = commentNode.getParent();
214            while (parentNode != null && !getObjectPrimaryType().equals(parentNode.getPrimaryNodeType().getName()))
215            {
216                if (parentNode.getName().startsWith(RepositoryConstants.NAMESPACE_PREFIX + ":" + AbstractComment.COMMENT_NAME_PREFIX))
217                {
218                    commentId
219                        .insert(0, AbstractComment.ID_SEPARATOR)
220                        .insert(0, StringUtils.removeStart(parentNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":"));
221                }
222                
223                parentNode = parentNode.getParent(); // continue iterating
224            }
225            
226            if (parentNode == null)
227            {
228                getLogger().error("Parent holder object not found from comment node '{}'", commentNodePath);
229                return null;
230            }
231            
232            return Pair.of(parentNode, commentId.toString());
233        }
234        catch (RepositoryException | AmetysRepositoryException e)
235        {
236            getLogger().error("Failed to retrieve parent holder and comment from comment node '{}'", commentNodePath,  e);
237            return null;
238        }
239    }
240    /**
241     * Handle reactions
242     * @param userStatusInfo the user status infos
243     * @param queryManager the query manager
244     * @return the number of handled reactions
245     * @throws RepositoryException if an error occurred
246     */
247    protected int handleCommentReactions(UserStatusInfo userStatusInfo, QueryManager queryManager) throws RepositoryException
248    {
249        int handled = 0;
250        for (ReactionType type : ReactionType.values())
251        {
252            // Find any node with the reaction type name under root comments of an object with the user as issuer
253            StringBuilder query = new StringBuilder("//element(*, " + getObjectPrimaryType() + ")/ametys:comments")
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 reactionable object
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                parentNode.getSession().save();
269                handled++;
270            }
271        }
272        
273        if (handled > 0)
274        {
275            ForensicLogger.info("data.policy.gdpr.remove." + getLogCategory() + ".reactions", Map.of("handled", Long.toString(handled), "identity", userStatusInfo.getUserIdentity()), UserPopulationDAO.SYSTEM_USER_IDENTITY);
276        }
277        
278        return handled;
279    }
280    
281    /**
282     * Record for processing result
283     * @param handled the number of elements handled
284     * @param failed the number of elements in error
285     */
286    protected static record HandlingResult(int handled, int failed) { }
287}