001/*
002 *  Copyright 2021 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.workflow.purge;
017
018import java.io.ByteArrayOutputStream;
019import java.io.IOException;
020import java.io.InputStream;
021import java.time.ZoneId;
022import java.util.Date;
023import java.util.HashMap;
024import java.util.Map;
025
026import org.apache.avalon.framework.activity.Initializable;
027import org.apache.avalon.framework.configuration.Configuration;
028import org.apache.avalon.framework.configuration.ConfigurationException;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.cocoon.components.ContextHelper;
032import org.apache.cocoon.environment.Request;
033import org.apache.commons.lang.StringUtils;
034import org.apache.commons.lang.exception.ExceptionUtils;
035import org.apache.excalibur.source.Source;
036import org.apache.excalibur.source.SourceResolver;
037import org.apache.excalibur.source.SourceUtil;
038import org.quartz.JobExecutionContext;
039
040import org.ametys.cms.repository.Content;
041import org.ametys.cms.repository.ContentQueryHelper;
042import org.ametys.cms.repository.WorkflowAwareContent;
043import org.ametys.core.schedule.progression.ContainerProgressionTracker;
044import org.ametys.core.ui.mail.StandardMailBodyHelper;
045import org.ametys.core.ui.mail.StandardMailBodyHelper.MailBodyBuilder;
046import org.ametys.core.util.DateUtils;
047import org.ametys.core.util.HttpUtils;
048import org.ametys.core.util.I18nUtils;
049import org.ametys.core.util.mail.SendMailHelper;
050import org.ametys.plugins.core.impl.schedule.AbstractStaticSchedulable;
051import org.ametys.plugins.repository.AmetysObjectIterable;
052import org.ametys.plugins.repository.AmetysObjectResolver;
053import org.ametys.plugins.workflow.support.WorkflowProvider;
054import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
055import org.ametys.runtime.config.Config;
056import org.ametys.runtime.i18n.I18nizableText;
057
058import jakarta.mail.MessagingException;
059
060/**
061 * Schedulable that removes old versions of contents.
062 */
063public class PurgeContentsSchedulable extends AbstractStaticSchedulable implements Initializable
064{
065    /** The server base URL. */
066    protected String _baseUrl;
067    
068    /** The ametys object resolver. */
069    protected AmetysObjectResolver _ametysResolver;
070    
071    /** The avalon source resolver. */
072    protected SourceResolver _sourceResolver;
073    
074    /** The version purger. */
075    protected PurgeVersionsManager _versionPurger;
076    
077    /** The workflow provider */
078    protected WorkflowProvider _workflowProvider;
079    
080    /** The i18n utils. */
081    protected I18nUtils _i18nUtils;
082    
083    /** A Map of the validation step ID by workflow name. */
084    protected Map<String, Long> _validationStepId;
085    
086    /** The count of oldest versions to keep. */
087    protected int _firstVersionsToKeep;
088    
089    /** The content of "from" field in emails. */
090    protected String _mailFrom;
091    
092    /** The sysadmin mail address, to which will be sent the report e-mail. */
093    protected String _sysadminMail;
094    
095    @Override
096    public void service(ServiceManager manager) throws ServiceException
097    {
098        super.service(manager);
099        // Lookup the needed components.
100        _ametysResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
101        _versionPurger = (PurgeVersionsManager) manager.lookup(PurgeVersionsManager.ROLE);
102        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
103        _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE);
104        
105        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
106    }
107    
108    public void initialize() throws Exception
109    {
110        _baseUrl = HttpUtils.sanitize(Config.getInstance().getValue("cms.url"));
111        _mailFrom = Config.getInstance().getValue("smtp.mail.from");
112        _sysadminMail = Config.getInstance().getValue("smtp.mail.sysadminto");
113    }
114    
115    @Override
116    public void configure(Configuration configuration) throws ConfigurationException
117    {
118        super.configure(configuration);
119        _validationStepId = configureValidationStepId(configuration);
120        _firstVersionsToKeep = configuration.getChild("firstVersionsToKeep").getValueAsInteger();
121    }
122    
123    /**
124     * Get the validation step ID by workflow from the component configuration.
125     * @param configuration the component configuration.
126     * @return a Map of the validation step ID by workflow.
127     * @throws ConfigurationException If an error occurred
128     */
129    protected Map<String, Long> configureValidationStepId(Configuration configuration) throws ConfigurationException
130    {
131        Map<String, Long> validationStepIds = new HashMap<>();
132        
133        for (Configuration workflowConf : configuration.getChildren("workflow"))
134        {
135            String workflowName = workflowConf.getAttribute("name");
136            Long validationStepId = workflowConf.getChild("validationStepId").getValueAsLong();
137            
138            validationStepIds.put(workflowName, validationStepId);
139        }
140        
141        return validationStepIds;
142    }
143
144    @Override
145    public void execute(JobExecutionContext context, ContainerProgressionTracker progressionTracker) throws Exception
146    {
147        String query = ContentQueryHelper.getContentXPathQuery(null);
148        
149        Date startDate = new Date();
150        
151        int totalContentsPurged = 0;
152        int totalVersionsPurged = 0;
153        
154        try
155        {
156            try (AmetysObjectIterable<Content> contents = _ametysResolver.query(query))
157            {
158                for (Content content : contents)
159                {
160                    if (content instanceof WorkflowAwareContent)
161                    {
162                        WorkflowAwareContent workflowContent = (WorkflowAwareContent) content;
163                        long workflowId = workflowContent.getWorkflowId();
164                        
165                        AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(workflowContent);
166                        String workflowName = workflow.getWorkflowName(workflowId);
167                        
168                        if (_validationStepId.containsKey(workflowName))
169                        {
170                            long validationStepId = _validationStepId.get(workflowName);
171                            
172                            int purged = _versionPurger.purgeContent(workflowContent, validationStepId, _firstVersionsToKeep);
173                            if (purged > 0)
174                            {
175                                totalVersionsPurged += purged;
176                                totalContentsPurged++;
177                            }
178                        }
179                    }
180                }
181            }
182            
183            Date endDate = new Date();
184            
185            sendMail(startDate, endDate, totalContentsPurged, totalVersionsPurged);
186        }
187        catch (Exception e)
188        {
189            sendErrorMail(startDate, e);
190        }
191        
192    }
193    
194    /**
195     * Send the purge report e-mail.
196     * @param startDate the purge start date.
197     * @param endDate the purge end date.
198     * @param totalContentsPurged the total count of contents of which versions were purged.
199     * @param totalVersionsPurged the total count of content versions removed.
200     */
201    protected void sendMail(Date startDate, Date endDate, int totalContentsPurged, int totalVersionsPurged)
202    {
203        try
204        {
205            String language = _userLanguagesManager.getDefaultLanguage();
206            Map<String, String> params = getEmailParams(startDate, endDate, totalContentsPurged, totalVersionsPurged);
207            
208            I18nizableText i18nSubject = new I18nizableText("plugin.cms", "PLUGINS_CMS_PURGE_CONTENTS_REPORT_SUBJECT");
209            
210            String subject = _i18nUtils.translate(i18nSubject, language);
211            String body = getMailBody(params, language);
212            
213            if (StringUtils.isNotBlank(_sysadminMail))
214            {
215                SendMailHelper.newMail()
216                              .withSubject(subject)
217                              .withHTMLBody(body)
218                              .withSender(_mailFrom)
219                              .withRecipient(_sysadminMail)
220                              .sendMail();
221            }
222        }
223        catch (MessagingException | IOException e)
224        {
225            getLogger().warn("Error sending the purge report e-mail.", e);
226        }
227    }
228    
229    /**
230     * Send the error e-mail.
231     * @param startDate the purge start date.
232     * @param throwable the error.
233     */
234    protected void sendErrorMail(Date startDate, Throwable throwable)
235    {
236        try
237        {
238            String language = _userLanguagesManager.getDefaultLanguage();
239            Map<String, String> params = getErrorEmailParams(startDate, throwable);
240            
241            I18nizableText i18nSubject = new I18nizableText("plugin.cms", "PLUGINS_CMS_PURGE_CONTENTS_REPORT_ERROR_SUBJECT");
242            
243            String subject = _i18nUtils.translate(i18nSubject, language);
244            String mailUri = getErrorMailUri(params);
245            String body = getMailBody(mailUri, params, language, throwable);
246            
247            if (StringUtils.isNotBlank(_sysadminMail))
248            {
249                SendMailHelper.newMail()
250                              .withSubject(subject)
251                              .withHTMLBody(body)
252                              .withSender(_mailFrom)
253                              .withRecipient(_sysadminMail)
254                              .sendMail();
255            }
256        }
257        catch (MessagingException | IOException e)
258        {
259            getLogger().warn("Error sending the purge error e-mail.", e);
260        }
261    }
262    
263    /**
264     * Get the report e-mail parameters.
265     * @param startDate the purge start date.
266     * @param endDate the purge end date.
267     * @param totalContentsPurged the total count of contents of which versions were purged.
268     * @param totalVersionsPurged the total count of content versions removed.
269     * @return the e-mail parameters.
270     */
271    protected Map<String, String> getEmailParams(Date startDate, Date endDate, int totalContentsPurged, int totalVersionsPurged)
272    {
273        Map<String, String> params = new HashMap<>();
274        
275        params.put("startDate", DateUtils.getISODateTimeFormatter().format(startDate.toInstant().atZone(ZoneId.systemDefault())));
276        params.put("endDate", DateUtils.getISODateTimeFormatter().format(endDate.toInstant().atZone(ZoneId.systemDefault())));
277        params.put("totalContentsPurged", Integer.toString(totalContentsPurged));
278        params.put("totalVersionsPurged", Integer.toString(totalVersionsPurged));
279        params.put("url", _baseUrl);
280        
281        return params;
282    }
283    
284    /**
285     * Get the error e-mail parameters.
286     * @param startDate the purge start date.
287     * @param throwable the error.
288     * @return the e-mail parameters.
289     */
290    protected Map<String, String> getErrorEmailParams(Date startDate, Throwable throwable)
291    {
292        Map<String, String> params = new HashMap<>();
293        
294        params.put("startDate", DateUtils.getISODateTimeFormatter().format(startDate.toInstant().atZone(ZoneId.systemDefault())));
295        params.put("url", _baseUrl);
296        
297        return params;
298    }
299    
300    /**
301     * Get a mail part.
302     * @param parameters the pipeline parameters.
303     * @param language The language to use for the mail.
304     * @return the mail part.
305     * @throws IOException If an error occurred
306     */
307    protected String getMailBody(Map<String, String> parameters, String language) throws IOException
308    {
309        String uri = getMailUri(parameters);
310        
311        return getMailBody(uri, parameters, language, null);
312    }
313    
314    /**
315     * Get a mail part.
316     * @param uri The url where to get the body of the mail
317     * @param parameters the pipeline parameters.
318     * @return the mail part.
319     * @throws IOException If an error occurred
320     */
321    private String getMailBody(String uri, Map<String, String> parameters, String language, Throwable throwable) throws IOException
322    {
323        Request request = ContextHelper.getRequest(_context);
324        Object previousLang = request.getAttribute("lang");
325        
326        Source source = null;
327        InputStream is = null;
328        try
329        {
330            request.setAttribute("lang", language);
331            
332            source = _sourceResolver.resolveURI(uri, null, parameters);
333            is = source.getInputStream();
334            
335            try (ByteArrayOutputStream bos = new ByteArrayOutputStream())
336            {
337                SourceUtil.copy(is, bos);
338                
339                String message = bos.toString("UTF-8");
340                MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
341                    .withTitle(new I18nizableText("plugin.cms", throwable != null ? "PLUGINS_CMS_PURGE_CONTENTS_REPORT_ERROR_SUBJECT" : "PLUGINS_CMS_PURGE_CONTENTS_REPORT_SUBJECT"))
342                    .withMessage(message)
343                    .withLink(_baseUrl, new I18nizableText("plugin.cms", "PLUGINS_CMS_PURGE_CONTENTS_REPORT_BODY_LINK"))
344                    .withLanguage(language);
345                
346                if (throwable != null)
347                {
348                    String stackTrace = ExceptionUtils.getStackTrace(throwable);
349                    bodyBuilder.withDetails(new I18nizableText("plugin.cms", "PLUGINS_CMS_PURGE_CONTENTS_REPORT_BODY_ERROR_TITLE"), stackTrace, true);
350                }
351                
352                return bodyBuilder.build();
353            }
354        }
355        finally
356        {
357            request.setAttribute("lang", previousLang);
358
359            if (is != null)
360            {
361                is.close();
362            }
363            
364            if (source != null)
365            {
366                _sourceResolver.release(source);
367            }
368        }
369    }
370    
371    /**
372     * Get the pipeline uri for mail body
373     * @param parameters the mail parameters
374     * @return a pipeline uri
375     */
376    protected String getMailUri(Map<String, String> parameters)
377    {
378        return "cocoon://_plugins/cms/purge/purge-mail.html";
379    }
380    
381    /**
382     * Get the pipeline uri for error mail body.
383     * @param parameters the mail parameters
384     * @return a pipeline uri
385     */
386    protected String getErrorMailUri(Map<String, String> parameters)
387    {
388        return "cocoon://_plugins/cms/purge/purge-error-mail.html";
389    }
390    
391}