001/*
002 *  Copyright 2012 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.archive;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.text.DateFormat;
021import java.util.ArrayList;
022import java.util.Date;
023import java.util.HashSet;
024import java.util.List;
025import java.util.Locale;
026import java.util.Map;
027import java.util.Set;
028import java.util.stream.Collectors;
029
030import org.apache.avalon.framework.configuration.Configuration;
031import org.apache.avalon.framework.configuration.ConfigurationException;
032import org.apache.avalon.framework.context.Context;
033import org.apache.avalon.framework.context.ContextException;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.cocoon.Constants;
037import org.apache.cocoon.components.ContextHelper;
038import org.apache.cocoon.environment.Request;
039import org.apache.commons.lang3.StringUtils;
040import org.apache.commons.lang3.tuple.Pair;
041import org.apache.excalibur.source.Source;
042import org.apache.excalibur.source.SourceResolver;
043import org.quartz.JobExecutionContext;
044import org.slf4j.Logger;
045import org.slf4j.LoggerFactory;
046
047import org.ametys.cms.content.ContentHelper;
048import org.ametys.cms.content.archive.ArchiveConstants;
049import org.ametys.cms.repository.Content;
050import org.ametys.cms.repository.ContentQueryHelper;
051import org.ametys.core.authentication.AuthenticateAction;
052import org.ametys.core.model.type.ModelItemTypeExtensionPoint;
053import org.ametys.core.right.RightManager;
054import org.ametys.core.schedule.progression.ContainerProgressionTracker;
055import org.ametys.core.ui.mail.StandardMailBodyHelper;
056import org.ametys.core.user.User;
057import org.ametys.core.user.UserIdentity;
058import org.ametys.core.user.UserManager;
059import org.ametys.core.user.population.PopulationContextHelper;
060import org.ametys.core.util.HttpUtils;
061import org.ametys.core.util.I18nUtils;
062import org.ametys.core.util.mail.SendMailHelper;
063import org.ametys.plugins.core.impl.schedule.AbstractStaticSchedulable;
064import org.ametys.plugins.repository.AmetysObjectIterable;
065import org.ametys.plugins.repository.AmetysObjectResolver;
066import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
067import org.ametys.plugins.repository.query.expression.DateExpression;
068import org.ametys.plugins.repository.query.expression.Expression;
069import org.ametys.plugins.repository.query.expression.Expression.Operator;
070import org.ametys.plugins.repository.query.expression.ExpressionContext;
071import org.ametys.runtime.config.Config;
072import org.ametys.runtime.i18n.I18nizableText;
073
074import jakarta.mail.MessagingException;
075
076/**
077 * Runnable engine that archive the contents that have an scheduled archiving
078 * date set before the current date.
079 */
080public class ArchiveContentsSchedulable extends AbstractStaticSchedulable
081{
082    /** The logger. */
083    protected Logger _logger = LoggerFactory.getLogger(ArchiveContentsSchedulable.class);
084
085    /** The service manager. */
086    protected ServiceManager _manager;
087
088    /** The server base URL. */
089    protected String _baseUrl;
090
091    /** The cocoon environment context. */
092    protected org.apache.cocoon.environment.Context _environmentContext;
093
094    /** The ametys object resolver. */
095    protected AmetysObjectResolver _ametysResolver;
096
097    /** The avalon source resolver. */
098    protected SourceResolver _sourceResolver;
099    
100    /** The rights manager */
101    protected RightManager _rightManager;
102    
103    /** The i18n utils. */
104    protected I18nUtils _i18nUtils;
105    
106    /** The content helper */
107    protected ContentHelper _contentHelper;
108    
109    /** The content of "from" field in emails. */
110    protected String _mailFrom;
111
112    /** The sysadmin mail address, to which will be sent the report e-mail. */
113    protected String _sysadminMail;
114    
115    /** The user e-mail notification will be sent to users that have this at least one of this rights. */
116    protected Set<String> _archiveRights;
117    
118    /** The user notification mail body i18n key. */
119    protected String _userMailBody;
120    
121    /** The user notification mail subject i18n key. */
122    protected String _userMailSubject;
123    
124    /** The user notification error mail body i18n key. */
125    protected String _userErrorMailBody;
126    
127    /** The user notification error mail subject i18n key. */
128    protected String _userErrorMailSubject;
129    
130
131    @Override
132    public void service(ServiceManager manager) throws ServiceException
133    {
134        super.service(manager);
135        _smanager = manager;
136        _schedulableParameterTypeExtensionPoint = (ModelItemTypeExtensionPoint) _smanager.lookup(ModelItemTypeExtensionPoint.ROLE_SCHEDULABLE);
137        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
138        
139        _manager = manager;
140        // Lookup the needed components.
141        _ametysResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
142        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
143        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
144        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
145        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
146        
147        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
148
149        _baseUrl = HttpUtils.sanitize(Config.getInstance().getValue("cms.url"));
150        _mailFrom = Config.getInstance().getValue("smtp.mail.from");
151        _sysadminMail = Config.getInstance().getValue("smtp.mail.sysadminto");
152    }
153
154    @Override
155    public void contextualize(Context context) throws ContextException
156    {
157        super.contextualize(context);
158        _environmentContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
159    }
160
161    /**
162     * Configure the engine (called by the scheduler).
163     * 
164     * @param configuration the component configuration.
165     * @throws ConfigurationException If an error occurred
166     */
167    @Override
168    public void configure(Configuration configuration) throws ConfigurationException
169    {
170        super.configure(configuration);
171        // Rights
172        Configuration rightsConf = configuration.getChild("rights");
173        
174        _archiveRights = new HashSet<>();
175        
176        for (Configuration rightConf : rightsConf.getChildren("right"))
177        {
178            String right = rightConf.getValue("");
179            if (StringUtils.isNotBlank(right))
180            {
181                _archiveRights.add(right);
182            }
183        }
184        
185        // Mails
186        Configuration validMailsConf = configuration.getChild("mails").getChild("valid");
187        Configuration errorMailsConf = configuration.getChild("mails").getChild("error");
188        
189        _userMailBody = validMailsConf.getChild("bodyKey").getValue();
190        _userMailSubject = validMailsConf.getChild("subjectKey").getValue();
191        
192        _userErrorMailBody = errorMailsConf.getChild("bodyKey").getValue();
193        _userErrorMailSubject = errorMailsConf.getChild("subjectKey").getValue();
194
195    }
196
197    @Override
198    public void execute(JobExecutionContext context, ContainerProgressionTracker progressionTracker) throws Exception
199    {
200        Request request = ContextHelper.getRequest(_context);
201        
202        // Get all the content which scheduled archiving date is passed.
203        Expression dateExpression = new DateExpression(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE, Operator.LE, new Date(), ExpressionContext.newInstance().withUnversioned(true));
204        String query = ContentQueryHelper.getContentXPathQuery(dateExpression);
205
206        try (AmetysObjectIterable<Content> contents = _ametysResolver.query(query))
207        {
208            List<Content> archivedContents = new ArrayList<>();
209            List<Content> contentsWithError = new ArrayList<>();
210           
211            try
212            {
213                // Authorize workflow actions and "check-auth" CMS action
214                request.setAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED, true);
215                
216                for (Content content : contents)
217                {
218                    setRequestAttributes(request, content);
219                    
220                    Map<String, Set<User>> usersByLanguage = _getAuthorizedContributorsByLanguage(content);
221                    
222                    Source source = null;
223                    try
224                    {
225                        String contentId = content.getId();
226                        
227                        String uri = getArchiveActionUri(contentId);
228                        source = _sourceResolver.resolveURI(uri);
229                        
230                        try (InputStream is =  source.getInputStream())
231                        {
232                            String workspaceBackup = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
233                            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, ArchiveConstants.ARCHIVE_WORKSPACE);
234                            
235                            Content archivedContent = _ametysResolver.resolveById(contentId);
236                            archivedContents.add(archivedContent);
237                            
238                            sendMailToContributors(archivedContent, usersByLanguage);
239                            
240                            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceBackup);
241                        }
242                    }
243                    catch (Exception e)
244                    {
245                        contentsWithError.add (content);
246                        sendErrorMailToContributors(content, usersByLanguage);
247                        _logger.error("Error while trying to archive the content : " + content.getId() + "\nThis content is probably not archived.", e);
248                    }
249                    finally
250                    {
251                        if (source != null)
252                        {
253                            _sourceResolver.release(source);
254                        }
255                    }
256                }
257            }
258            finally
259            {
260                request.removeAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED);
261                
262                // Send mail to administrator
263                sendMailToAdministrator(archivedContents, contentsWithError);
264            }
265        }
266    }
267    
268    /**
269     * Set the necessary request attributes
270     * @param request The request
271     * @param content The content
272     */
273    protected void setRequestAttributes (Request request, Content content)
274    {
275        // Set the population contexts to be able to get allowed users
276        List<String> populationContexts = new ArrayList<>();
277        populationContexts.add("/application");
278        
279        request.setAttribute(PopulationContextHelper.POPULATION_CONTEXTS_REQUEST_ATTR, populationContexts);
280    }
281    
282    /**
283     * Get the pipeline uri for the archive action
284     * @param contentId the current contend id
285     * @return a pipeline uri
286     */
287    protected String getArchiveActionUri(String contentId)
288    {
289        return "cocoon://_plugins/cms/archives/archive/" + ArchiveConstants.ARCHIVE_WORKFLOW_ACTION_ID + "?contentId=" + contentId;
290    }
291
292    /**
293     * Send the archive report e-mail.
294     * @param archivedContents The list of archived contents
295     * @param contentsWithError The list of contents with error
296     */
297    protected void sendMailToAdministrator(List<Content> archivedContents, List<Content> contentsWithError)
298    {
299        if (archivedContents.size() != 0 || contentsWithError.size() != 0)
300        {
301            try
302            {
303                String language = _userLanguagesManager.getDefaultLanguage();
304                I18nizableText i18nSubject = new I18nizableText("plugin.cms", "PLUGINS_CMS_ARCHIVE_CONTENTS_REPORT_SUBJECT");
305                
306                List<String> bodyParams = getAdminEmailParams(archivedContents, contentsWithError, Locale.of(language));
307                I18nizableText i18nBody = new I18nizableText("plugin.cms", "PLUGINS_CMS_ARCHIVE_CONTENTS_REPORT_BODY", bodyParams);
308                
309                String subject = _i18nUtils.translate(i18nSubject, language);
310                
311                String body = StandardMailBodyHelper.newHTMLBody()
312                        .withTitle(i18nSubject)
313                        .withMessage(i18nBody)
314                        .withLink(_getRequestURI(null) + "/index.html", new I18nizableText("plugin.cms", "PLUGINS_CMS_ARCHIVE_CONTENTS_REPORT_BODY_LINK"))
315                        .withLanguage(language)
316                        .build();
317                
318                if (StringUtils.isNotBlank(_sysadminMail))
319                {
320                    SendMailHelper.newMail()
321                                  .withSubject(subject)
322                                  .withHTMLBody(body)
323                                  .withSender(_mailFrom)
324                                  .withRecipient(_sysadminMail)
325                                  .sendMail();
326                }
327            }
328            catch (MessagingException | IOException e)
329            {
330                _logger.warn("Error sending the archive report e-mail.", e);
331            }
332        }
333    }
334    
335
336    /**
337     * Get the report e-mail parameters.
338     * @param archivedContents The list of archived contents
339     * @param contentsWithError The list of contents with error
340     * @param locale The locale
341     * @return the e-mail parameters.
342     */
343    protected List<String> getAdminEmailParams(List<Content> archivedContents, List<Content> contentsWithError, Locale locale)
344    {
345        List<String> params = new ArrayList<>();
346        
347        DateFormat df = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, locale);
348        params.add(df.format(new Date())); // {0}
349        
350        params.add(String.valueOf(archivedContents.size())); // {1}
351        params.add(_getContentsListAsString(archivedContents, locale)); // {2}
352
353        params.add(String.valueOf(contentsWithError.size())); // {3}
354        params.add(_getContentsListAsString(contentsWithError, locale)); // {4}
355
356        return params;
357    }
358    
359    /**
360     * Get the contents list as String
361     * @param contents The contents
362     * @param locale The locale in which to get the contents
363     * @return the list of contents as String
364     */
365    protected String _getContentsListAsString (List<Content> contents, Locale locale)
366    {
367        List<String> contentNames = contents.stream()
368            .map(content -> this._getContentTitle(content, locale))
369            .collect(Collectors.toList());
370        
371        if (!contentNames.isEmpty())
372        {
373            StringBuilder sb = new StringBuilder();
374            sb.append("<ul>");
375            contentNames.forEach(c -> sb.append("<li>").append(c).append("</li>"));
376            sb.append("</ul>");
377            
378            return sb.toString();
379        }
380        else
381        {
382            return "";
383        }
384    }
385    
386    /**
387     * Get content title for mail
388     * @param content the content
389     * @param locale The locale in which to get the content title
390     * @return the content title
391     */
392    protected String _getContentTitle(Content content, Locale locale)
393    {
394        return content.getTitle(locale);
395    }
396    
397    /**
398     * Get the authorized contributors to receive mail notification
399     * @param content The content to be archived
400     * @return The user logins
401     */
402    protected Map<String, Set<User>> _getAuthorizedContributorsByLanguage (Content content)
403    {
404        Set<UserIdentity> users = new HashSet<>();
405        for (String right : _archiveRights)
406        {
407            users.addAll(_rightManager.getAllowedUsers(right, content).resolveAllowedUsers(Config.getInstance().getValue("runtime.mail.massive.sending")));
408        }
409        
410        String defaultLanguage = _userLanguagesManager.getDefaultLanguage();
411        
412        return users.stream()
413            .map(userId -> _userManager.getUser(userId))
414            .filter(user -> user != null)
415            .map(user -> Pair.of(user.getLanguage(), user))
416            .collect(Collectors.groupingBy(
417                    p -> {
418                        return StringUtils.defaultIfBlank(p.getLeft(), defaultLanguage);
419                    },
420                    Collectors.mapping(
421                            Pair::getRight,
422                            Collectors.toSet()
423                    )
424                )
425            );
426    }
427
428    /**
429     * Send the mail to alert users that the content has been archived.
430     * @param content The archived content
431     * @param usersByLanguage The users by language
432     */
433    protected void sendMailToContributors(Content content, Map<String, Set<User>> usersByLanguage)
434    {
435        // TODO TAC RETESTER
436        for (String language : usersByLanguage.keySet())
437        {
438            try
439            {
440                List<String> params = getBodyParamsForContributors (content, true, Locale.of(language));
441                
442                I18nizableText i18nSubject = new I18nizableText(null, _userMailSubject, params);
443                String htmlSubject = _i18nUtils.translate(i18nSubject, language);
444                
445                I18nizableText i18nBody = new I18nizableText(null, _userMailBody, params);
446                String htmlBody = StandardMailBodyHelper.newHTMLBody()
447                        .withTitle(i18nSubject)
448                        .withMessage(i18nBody)
449                        .withLink(getContentUri(content, true), new I18nizableText("plugin.cms", "CONTENT_ARCHIVE_MAIL_CONTENT_BODY_CONTENT_LINK"))
450                        .withLanguage(language)
451                        .build();
452                
453                _sendMailsToUsers(htmlSubject, htmlBody, usersByLanguage.get(language), _mailFrom);
454            }
455            catch (Exception e)
456            {
457                _logger.error("Unable to send mail to contributors after archiving content '" + content.getName() + "'", e);
458            }
459        }
460    }
461    
462    /**
463     * Get email body parameters
464     * @param content the archived content
465     * @param archived true if the content has archived
466     * @param locale The locale of the mail. Cannot be null.
467     * @return The mail parameters
468     */
469    protected List<String> getBodyParamsForContributors (Content content, boolean archived, Locale locale)
470    {
471        List<String> params = new ArrayList<>();
472        
473        params.add(content.getTitle(locale)); // {0}
474        params.add(DateFormat.getDateInstance(DateFormat.LONG, locale).format(new Date())); // {1}
475        params.add(getContentUri(content, archived));
476        
477        return params;
478    }
479    
480    /**
481     * Get the content uri
482     * @param content the archived content
483     * @param archived true if the content has archived
484     * @return the content uri
485     */
486    protected String getContentUri(Content content, boolean archived)
487    {
488        String contentBOUrl = _contentHelper.getContentBOUrl(content, Map.of());
489        if (archived)
490        {
491            return contentBOUrl + ",workspace:%27archives%27,%27ignore-workflow%27:%27true%27,%27content-message-type%27:%27archived-content%27";
492        }
493        else
494        {
495            return contentBOUrl;
496        }
497    }
498    
499    /**
500     * Send the mail to alert users that an error has occurred while trying to archive the content.
501     * @param content The content
502     * @param usersByLanguage The users by language
503     */
504    protected void sendErrorMailToContributors(Content content, Map<String, Set<User>> usersByLanguage)
505    {
506        // TODO TAC RETESTER
507        for (String language : usersByLanguage.keySet())
508        {
509            try
510            {
511                List<String> params = getBodyParamsForContributors (content, false, Locale.of(language));
512                
513                I18nizableText i18nSubject = new I18nizableText(null, _userErrorMailSubject, params);
514                String htmlSubject = _i18nUtils.translate(i18nSubject, language);
515
516                I18nizableText i18nBody = new I18nizableText(null, _userErrorMailBody, params);
517                
518                String htmlBody = StandardMailBodyHelper.newHTMLBody()
519                        .withTitle(i18nSubject)
520                        .withMessage(i18nBody)
521                        .withLink(getContentUri(content, false), new I18nizableText("plugin.cms", "CONTENT_ARCHIVE_MAIL_CONTENT_BODY_CONTENT_LINK"))
522                        .withLanguage(language)
523                        .build();
524                
525                _sendMailsToUsers(htmlSubject, htmlBody, usersByLanguage.get(language), _mailFrom);
526            }
527            catch (Exception e)
528            {
529                _logger.error("Unable to send mail to contributors after archiving content '" + content.getName() + "' failed", e);
530            }
531        }
532    }
533    
534    /**
535     * Get the request URI to set in mail
536     * @param content The content. Can be null
537     * @return the request URI
538     */
539    protected String _getRequestURI (Content content)
540    {
541        return _baseUrl;
542    }
543
544    /**
545     * Send the emails to users (contributors)
546     * @param subject the e-mail subject.
547     * @param htmlBody the e-mail body.
548     * @param users users to send the mail to.
549     * @param from the address sending the e-mail.
550     */
551    protected void _sendMailsToUsers(String subject, String htmlBody, Set<User> users, String from)
552    {
553        for (User user : users)
554        {
555            String email = user.getEmail();
556            if (StringUtils.isNotBlank(email))
557            {
558                try
559                {
560                    SendMailHelper.newMail()
561                        .withSubject(subject)
562                        .withHTMLBody(htmlBody)
563                        .withSender(from)
564                        .withRecipient(email)
565                        .sendMail();
566                }
567                catch (MessagingException | IOException e)
568                {
569                    if (_logger.isWarnEnabled())
570                    {
571                        _logger.warn("Could not send an archive notification e-mail to " + email, e);
572                    }
573                }
574            }
575        }
576    }
577}