001/*
002 *  Copyright 2011 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.configuration.Configuration;
027import org.apache.avalon.framework.configuration.ConfigurationException;
028import org.apache.avalon.framework.context.Context;
029import org.apache.avalon.framework.context.ContextException;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.cocoon.Constants;
033import org.apache.cocoon.environment.ObjectModelHelper;
034import org.apache.cocoon.environment.Request;
035import org.apache.cocoon.util.log.SLF4JLoggerAdapter;
036import org.apache.commons.lang.StringUtils;
037import org.apache.commons.lang.exception.ExceptionUtils;
038import org.apache.excalibur.source.Source;
039import org.apache.excalibur.source.SourceResolver;
040import org.apache.excalibur.source.SourceUtil;
041import org.slf4j.Logger;
042import org.slf4j.LoggerFactory;
043
044import org.ametys.cms.repository.Content;
045import org.ametys.cms.repository.ContentQueryHelper;
046import org.ametys.cms.repository.WorkflowAwareContent;
047import org.ametys.core.authentication.AuthenticateAction;
048import org.ametys.core.engine.BackgroundEngineHelper;
049import org.ametys.core.engine.BackgroundEnvironment;
050import org.ametys.core.util.DateUtils;
051import org.ametys.core.util.I18nUtils;
052import org.ametys.core.util.mail.SendMailHelper;
053import org.ametys.plugins.repository.AmetysObjectIterable;
054import org.ametys.plugins.repository.AmetysObjectResolver;
055import org.ametys.plugins.repository.AmetysRepositoryException;
056import org.ametys.plugins.workflow.support.WorkflowProvider;
057import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
058import org.ametys.runtime.config.Config;
059import org.ametys.runtime.i18n.I18nizableText;
060
061import jakarta.mail.MessagingException;
062
063/**
064 * Runnable engine that removes old versions of contents.
065 */
066public class PurgeContentsEngine implements Runnable
067{
068    
069    /** The logger. */
070    protected static final Logger _LOGGER = LoggerFactory.getLogger(PurgeContentsEngine.class);
071    
072    /** The avalon context. */
073    protected Context _context;
074    
075    /** The service manager. */
076    protected ServiceManager _manager;
077    
078    /** The server base URL. */
079    protected String _baseUrl;
080    
081    /** Is the engine initialized ? */
082    protected boolean _initialized;
083    
084    /** The cocoon environment context. */
085    protected org.apache.cocoon.environment.Context _environmentContext;
086    
087    /** The ametys object resolver. */
088    protected AmetysObjectResolver _ametysResolver;
089    
090    /** The avalon source resolver. */
091    protected SourceResolver _sourceResolver;
092    
093    /** The version purger. */
094    protected PurgeVersionsManager _versionPurger;
095    
096    /** The workflow provider */
097    protected WorkflowProvider _workflowProvider;
098    
099    /** The i18n utils. */
100    protected I18nUtils _i18nUtils;
101    
102    /** A Map of the validation step ID by workflow name. */
103    protected Map<String, Long> _validationStepId;
104    
105    /** The count of oldest versions to keep. */
106    protected int _firstVersionsToKeep;
107    
108    /** The content of "from" field in emails. */
109    protected String _mailFrom;
110    
111    /** The sysadmin mail address, to which will be sent the report e-mail. */
112    protected String _sysadminMail;
113    
114    /**
115     * Initialize the purge engine.
116     * @param manager the avalon service manager.
117     * @param context the avalon context.
118     * @throws ContextException If an error occurred
119     * @throws ServiceException If an error occurred
120     */
121    public void initialize(ServiceManager manager, Context context) throws ContextException, ServiceException
122    {
123        _manager = manager;
124        _context = context;
125        _environmentContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
126        
127        // Lookup the needed components.
128        _ametysResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
129        _versionPurger = (PurgeVersionsManager) manager.lookup(PurgeVersionsManager.ROLE);
130        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
131        _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE);
132        
133        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
134        
135        _baseUrl = StringUtils.stripEnd(StringUtils.removeEndIgnoreCase(Config.getInstance().getValue("cms.url"), "index.html"), "/");
136        _mailFrom = Config.getInstance().getValue("smtp.mail.from");
137        _sysadminMail = Config.getInstance().getValue("smtp.mail.sysadminto");
138        
139        _initialized = true;
140    }
141    
142    /**
143     * Configure the engine (called by the scheduler).
144     * @param configuration the component configuration.
145     * @throws ConfigurationException If an error occurred
146     */
147    public void configure(Configuration configuration) throws ConfigurationException
148    {
149        _validationStepId = configureValidationStepId(configuration);
150        _firstVersionsToKeep = configuration.getChild("firstVersionsToKeep").getValueAsInteger();
151    }
152    
153    /**
154     * Get the validation step ID by workflow from the component configuration.
155     * @param configuration the component configuration.
156     * @return a Map of the validation step ID by workflow.
157     * @throws ConfigurationException If an error occurred
158     */
159    protected Map<String, Long> configureValidationStepId(Configuration configuration) throws ConfigurationException
160    {
161        Map<String, Long> validationStepIds = new HashMap<>();
162        
163        for (Configuration workflowConf : configuration.getChildren("workflow"))
164        {
165            String workflowName = workflowConf.getAttribute("name");
166            Long validationStepId = workflowConf.getChild("validationStepId").getValueAsLong();
167            
168            validationStepIds.put(workflowName, validationStepId);
169        }
170        
171        return validationStepIds;
172    }
173    
174    /**
175     * Check the initialization and throw an exception if not initialized.
176     */
177    protected void checkInitialization()
178    {
179        if (!_initialized)
180        {
181            String message = "Le composant de synchronisation doit être initialisé avant d'être lancé.";
182            _LOGGER.error(message);
183            throw new IllegalStateException(message);
184        }
185    }
186    
187    @Override
188    public void run()
189    {
190        Map<String, Object> environmentInformation = null;
191        long start = System.currentTimeMillis();
192        long duration = 0;
193        try
194        {
195            _LOGGER.info("Preparing to purge the contents...");
196            
197            checkInitialization();
198            
199            // Create the environment.
200            environmentInformation = BackgroundEngineHelper.createAndEnterEngineEnvironment(_manager, _environmentContext, new SLF4JLoggerAdapter(_LOGGER));
201            
202            BackgroundEnvironment environment = (BackgroundEnvironment) environmentInformation.get("environment");
203            
204            // Authorize workflow actions and "check-auth" CMS action, from this background environment 
205            Request request = (Request) environment.getObjectModel().get(ObjectModelHelper.REQUEST_OBJECT);
206            request.setAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED, true);
207            
208            // Get all the contents and purge the old versions.
209            purgeContents();
210        }
211        catch (Exception e)
212        {
213            _LOGGER.error("An error occurred purging the contents.", e);
214            sendErrorMail(new Date(start), e);
215        }
216        finally
217        {
218            long end = System.currentTimeMillis();
219            
220            duration = (end - start) / 1000;
221            
222            // Leave the environment.
223            if (environmentInformation != null)
224            {
225                BackgroundEngineHelper.leaveEngineEnvironment(environmentInformation);
226            }
227            
228            // Dispose of the resources.
229            dispose();
230            _LOGGER.info("Contents purge ended after " + duration + " seconds.");
231        }
232    }
233    
234    /**
235     * Dispose of the resources and looked-up components.
236     */
237    protected void dispose()
238    {
239        // Release the components.
240        if (_manager != null)
241        {
242            _manager.release(_ametysResolver);
243        }
244        
245        _ametysResolver = null;
246        
247        _environmentContext = null;
248        _context = null;
249        _manager = null;
250        
251        _initialized = false;
252    }
253    
254    /**
255     * Get all the contents and purge the old versions.
256     * @throws AmetysRepositoryException if an error occurs.
257     */
258    protected void purgeContents() throws AmetysRepositoryException
259    {
260        String query = ContentQueryHelper.getContentXPathQuery(null);
261        
262        Date startDate = new Date();
263        
264        int totalContentsPurged = 0;
265        int totalVersionsPurged = 0;
266        
267        try (AmetysObjectIterable<Content> contents = _ametysResolver.query(query))
268        {
269            for (Content content : contents)
270            {
271                if (content instanceof WorkflowAwareContent)
272                {
273                    WorkflowAwareContent workflowContent = (WorkflowAwareContent) content;
274                    long workflowId = workflowContent.getWorkflowId();
275                    
276                    AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(workflowContent);
277                    String workflowName = workflow.getWorkflowName(workflowId);
278                    
279                    if (_validationStepId.containsKey(workflowName))
280                    {
281                        long validationStepId = _validationStepId.get(workflowName);
282                        
283                        int purged = _versionPurger.purgeContent(workflowContent, validationStepId, _firstVersionsToKeep);
284                        if (purged > 0)
285                        {
286                            totalVersionsPurged += purged;
287                            totalContentsPurged++;
288                        }
289                    }
290                }
291            }
292        }
293        
294        Date endDate = new Date();
295        
296        sendMail(startDate, endDate, totalContentsPurged, totalVersionsPurged);
297    }
298    
299    /**
300     * Send the purge report e-mail.
301     * @param startDate the purge start date.
302     * @param endDate the purge end date.
303     * @param totalContentsPurged the total count of contents of which versions were purged.
304     * @param totalVersionsPurged the total count of content versions removed.
305     */
306    protected void sendMail(Date startDate, Date endDate, int totalContentsPurged, int totalVersionsPurged)
307    {
308        try
309        {
310            Map<String, String> params = getEmailParams(startDate, endDate, totalContentsPurged, totalVersionsPurged);
311            
312            I18nizableText i18nSubject = new I18nizableText("plugin.cms", "PLUGINS_CMS_PURGE_CONTENTS_REPORT_SUBJECT");
313            
314            String subject = _i18nUtils.translate(i18nSubject);
315            String body = getMailBody(params);
316            
317            if (StringUtils.isNotBlank(_sysadminMail))
318            {
319                SendMailHelper.newMail()
320                              .withSubject(subject)
321                              .withTextBody(body)
322                              .withSender(_mailFrom)
323                              .withRecipient(_sysadminMail)
324                              .sendMail();
325            }
326        }
327        catch (MessagingException | IOException e)
328        {
329            _LOGGER.warn("Error sending the purge report e-mail.", e);
330        }
331    }
332    
333    /**
334     * Send the error e-mail.
335     * @param startDate the purge start date.
336     * @param throwable the error.
337     */
338    protected void sendErrorMail(Date startDate, Throwable throwable)
339    {
340        try
341        {
342            Map<String, String> params = getErrorEmailParams(startDate, throwable);
343            
344            I18nizableText i18nSubject = new I18nizableText("plugin.cms", "PLUGINS_CMS_PURGE_CONTENTS_REPORT_ERROR_SUBJECT");
345            
346            String subject = _i18nUtils.translate(i18nSubject);
347            String mailUri = getErrorMailUri(params);
348            String body = getMailBody(mailUri, params);
349            
350            if (StringUtils.isNotBlank(_sysadminMail))
351            {
352                SendMailHelper.newMail()
353                              .withSubject(subject)
354                              .withTextBody(body)
355                              .withSender(_mailFrom)
356                              .withRecipient(_sysadminMail)
357                              .sendMail();
358            }
359        }
360        catch (MessagingException | IOException e)
361        {
362            _LOGGER.warn("Error sending the purge error e-mail.", e);
363        }
364    }
365    
366    /**
367     * Get the report e-mail parameters.
368     * @param startDate the purge start date.
369     * @param endDate the purge end date.
370     * @param totalContentsPurged the total count of contents of which versions were purged.
371     * @param totalVersionsPurged the total count of content versions removed.
372     * @return the e-mail parameters.
373     */
374    protected Map<String, String> getEmailParams(Date startDate, Date endDate, int totalContentsPurged, int totalVersionsPurged)
375    {
376        Map<String, String> params = new HashMap<>();
377        
378        params.put("startDate", DateUtils.getISODateTimeFormatter().format(startDate.toInstant().atZone(ZoneId.systemDefault())));
379        params.put("endDate", DateUtils.getISODateTimeFormatter().format(endDate.toInstant().atZone(ZoneId.systemDefault())));
380        params.put("totalContentsPurged", Integer.toString(totalContentsPurged));
381        params.put("totalVersionsPurged", Integer.toString(totalVersionsPurged));
382        params.put("url", _baseUrl);
383        
384        return params;
385    }
386    
387    /**
388     * Get the error e-mail parameters.
389     * @param startDate the purge start date.
390     * @param throwable the error.
391     * @return the e-mail parameters.
392     */
393    protected Map<String, String> getErrorEmailParams(Date startDate, Throwable throwable)
394    {
395        Map<String, String> params = new HashMap<>();
396        
397        params.put("startDate", DateUtils.getISODateTimeFormatter().format(startDate.toInstant().atZone(ZoneId.systemDefault())));
398        params.put("stackTrace", ExceptionUtils.getStackTrace(throwable));
399        params.put("url", _baseUrl);
400        
401        return params;
402    }
403    
404    /**
405     * Get a mail part.
406     * @param parameters the pipeline parameters.
407     * @return the mail part.
408     * @throws IOException If an error occurred
409     */
410    protected String getMailBody(Map<String, String> parameters) throws IOException
411    {
412        String uri = getMailUri(parameters);
413        
414        return getMailBody(uri, parameters);
415    }
416    
417    /**
418     * Get a mail part.
419     * @param uri The url where to get the body of the mail
420     * @param parameters the pipeline parameters.
421     * @return the mail part.
422     * @throws IOException If an error occurred
423     */
424    private String getMailBody(String uri, Map<String, String> parameters) throws IOException
425    {
426        Source source = null;
427        InputStream is = null;
428        try
429        {
430            source = _sourceResolver.resolveURI(uri, null, parameters);
431            is = source.getInputStream();
432            
433            try (ByteArrayOutputStream bos = new ByteArrayOutputStream())
434            {
435                SourceUtil.copy(is, bos);
436                
437                return bos.toString("UTF-8");
438            }
439        }
440        finally
441        {
442            if (is != null)
443            {
444                is.close();
445            }
446            
447            if (source != null)
448            {
449                _sourceResolver.release(source);
450            }
451        }
452    }
453    
454    /**
455     * Get the pipeline uri for mail body
456     * @param parameters the mail parameters
457     * @return a pipeline uri 
458     */
459    protected String getMailUri(Map<String, String> parameters)
460    {
461        return "cocoon://_plugins/cms/purge/purge-mail.html";
462    }
463    
464    /**
465     * Get the pipeline uri for error mail body.
466     * @param parameters the mail parameters
467     * @return a pipeline uri 
468     */
469    protected String getErrorMailUri(Map<String, String> parameters)
470    {
471        return "cocoon://_plugins/cms/purge/purge-error-mail.html";
472    }
473    
474}