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