001/*
002 *  Copyright 2020 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.core.ui.script;
017
018import java.io.File;
019import java.io.IOException;
020import java.time.Duration;
021import java.time.ZoneId;
022import java.time.ZonedDateTime;
023import java.time.format.DateTimeFormatter;
024import java.time.format.FormatStyle;
025import java.util.ArrayList;
026import java.util.List;
027import java.util.Locale;
028import java.util.Map;
029import java.util.Optional;
030import java.util.function.Function;
031
032import javax.mail.MessagingException;
033
034import org.apache.avalon.framework.activity.Initializable;
035import org.apache.commons.lang3.StringUtils;
036import org.apache.commons.lang3.time.DurationFormatUtils;
037import org.slf4j.Logger;
038
039import org.ametys.core.user.UserIdentity;
040import org.ametys.core.util.DateUtils;
041import org.ametys.core.util.mail.SendMailHelper;
042import org.ametys.runtime.config.Config;
043import org.ametys.runtime.i18n.I18nizableText;
044import org.ametys.runtime.plugin.PluginsManager;
045
046/**
047 * Component able to execute scripts asynchronously, either from a schedulable or because the user asked to do so.
048 * Script results are sent by email. 
049 */
050public class AsyncScriptHandler extends ScriptHandler implements Initializable
051{
052    /** Avalon role. */
053    public static final String COMPONENT_ROLE = AsyncScriptHandler.class.getName();
054    
055    private static final String __ASYNC_EXECUTE_MAIL_SUBJECT_KEY = "PLUGINS_CORE_UI_SCRIPT_ASYNC_EXECUTE_MAIL_SUBJECT";
056    private static final String __ASYNC_EXECUTE_MAIL_SUBJECT_ERROR_KEY = "PLUGINS_CORE_UI_SCRIPT_ASYNC_EXECUTE_MAIL_SUBJECT_ERROR";
057    private static final String __ASYNC_EXECUTE_MAIL_BODY_KEY = "PLUGINS_CORE_UI_SCRIPT_ASYNC_EXECUTE_MAIL_BODY";
058    private static final String __ASYNC_EXECUTE_MAIL_UNDEFINED = "PLUGINS_CORE_UI_SCRIPT_ASYNC_EXECUTE_MAIL_UNDEFINED";
059    
060    private Function<String, String> _mailSenderProvider;
061    
062    @Override
063    public void initialize() throws Exception
064    {
065        _initializeMailSenderProvider();
066    }
067    
068    /**
069     * Initializes {@link #_mailSenderProvider}. The argument of the provider is the mail of the recipient (as string)
070     */
071    protected void _initializeMailSenderProvider()
072    {
073        if (PluginsManager.getInstance().isSafeMode())
074        {
075            // Safe mode, the sender will be the recipient of the mail (because of the absence of information about a potential sender in this mode)
076            _mailSenderProvider = mailRecipient -> mailRecipient;
077        }
078        else
079        {
080            String mailSender = Config.getInstance().getValue("smtp.mail.from");
081            // Normal mode, the sender will always be the sender of the Configuration
082            _mailSenderProvider = mailRecipient -> mailSender;
083        }
084    }
085    
086    @Override
087    protected ResultProcessor getProcessor()
088    {
089        return new AsyncResultProcessor();
090    }
091    
092    /**
093     * Send a mail report from the result of a script
094     * @param scriptResults The result of the script
095     * @param user The user
096     * @param mailRecipient The recipient of the result mail
097     * @param locale The user locale
098     * @param logger The logger
099     * @throws MessagingException If an error occurred while preparing or sending email
100     * @throws IOException If an error occurred with an attachment.
101     */
102    public void sendReportMail(Map<String, Object> scriptResults, UserIdentity user, String mailRecipient, Locale locale, Logger logger) throws MessagingException, IOException
103    {
104        if (StringUtils.isNotBlank(mailRecipient)) 
105        {
106            String output = (String) scriptResults.get("output");
107            String error = StringUtils.defaultString((String) scriptResults.get("error"));
108            String errorMessage = StringUtils.defaultString((String) scriptResults.get("message"));
109            String errorStacktrace = StringUtils.defaultString((String) scriptResults.get("stacktrace"));
110            
111            @SuppressWarnings("unchecked")
112            List<File> attachments = (List<File>) scriptResults.get("attachments");
113            
114            Object result = scriptResults.getOrDefault("result", "");
115
116            String i18nCatalog = "plugin.core-ui";
117            I18nizableText undefinedText = new I18nizableText(i18nCatalog, __ASYNC_EXECUTE_MAIL_UNDEFINED);
118            
119            // Define start date and duration
120            ZonedDateTime startDate = _getZDTFromScriptResults(scriptResults, "start");
121            ZonedDateTime endDate = startDate != null ? _getZDTFromScriptResults(scriptResults, "end") : null;
122            
123            I18nizableText duration = Optional.ofNullable(endDate)
124                .map(end -> Duration.between(startDate, endDate))
125                .map(Duration::toMillis)
126                .map(DurationFormatUtils::formatDurationHMS)
127                .map(I18nizableText::new)
128                .orElse(undefinedText);
129            
130            I18nizableText start = Optional.ofNullable(startDate)
131                .map(DateTimeFormatter
132                    .ofLocalizedDateTime(FormatStyle.LONG)
133                    .withLocale(locale)
134                    .withZone(ZoneId.systemDefault())
135                    ::format
136                )
137                .map(I18nizableText::new)
138                .orElse(undefinedText);
139            
140            String htmlBody = _i18nUtils.translate(
141                new I18nizableText(
142                    i18nCatalog, 
143                    __ASYNC_EXECUTE_MAIL_BODY_KEY, 
144                    Map.of(
145                        "out", new I18nizableText(output), 
146                        "htmlResult", new I18nizableText(ScriptResultFormatter.htmlFormatResult(result)), 
147                        "error", new I18nizableText(error), 
148                        "message", new I18nizableText(errorMessage), 
149                        "htmlStacktrace", new I18nizableText(ScriptResultFormatter.htmlFormatStacktrace(errorStacktrace)), 
150                        "start", start,
151                        "duration", duration
152                    )
153                )
154            ); 
155            String title = _mailTitle(i18nCatalog, errorStacktrace);
156            
157            String mailSender = _mailSenderProvider.apply(mailRecipient);
158            SendMailHelper.sendMail(title, htmlBody, null, attachments, mailRecipient, mailSender);
159        }
160        else
161        {
162            logger.warn("User {} launched an asynchronous script but there is no email to send the output.", user);
163        }
164    }
165    
166    private ZonedDateTime _getZDTFromScriptResults(Map<String, Object> scriptResults, String paramName)
167    {
168        return Optional.of(paramName)
169                .map(scriptResults::get)
170                .map(String.class::cast)
171                .map(DateUtils::parseZonedDateTime)
172                .orElse(null);
173    }
174    
175    private String _mailTitle(String i18nCatalog, String errorStacktrace)
176    {
177        String i18nKey = StringUtils.isEmpty(errorStacktrace)
178                ? __ASYNC_EXECUTE_MAIL_SUBJECT_KEY
179                : __ASYNC_EXECUTE_MAIL_SUBJECT_ERROR_KEY;
180        return _i18nUtils.translate(new I18nizableText(i18nCatalog, i18nKey));
181    }
182    
183    static class AsyncResultProcessor extends ResultProcessor
184    {
185        @Override
186        protected Object process(Map<String, Object> results, Object scriptResult)
187        {
188            if (scriptResult instanceof File)
189            {
190                @SuppressWarnings("unchecked")
191                List<File> attachments = (List<File>) results.computeIfAbsent("attachments", __ -> new ArrayList<>());
192                attachments.add((File) scriptResult);
193                
194                return ((File) scriptResult).getName();
195            }
196            
197            return super.process(results, scriptResult);
198        }
199    }
200}