001/*
002 *  Copyright 2017 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.zimbra;
017
018import java.io.IOException;
019import java.io.StringReader;
020import java.nio.charset.StandardCharsets;
021import java.time.Duration;
022import java.time.Instant;
023import java.time.ZonedDateTime;
024import java.util.ArrayList;
025import java.util.Collections;
026import java.util.Date;
027import java.util.List;
028import java.util.Map;
029import java.util.TreeMap;
030import java.util.stream.Collectors;
031
032import org.apache.avalon.framework.activity.Disposable;
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.cocoon.ProcessingException;
036import org.apache.cocoon.environment.Redirector;
037import org.apache.commons.lang3.StringUtils;
038import org.apache.excalibur.xml.dom.DOMParser;
039import org.apache.excalibur.xml.xpath.PrefixResolver;
040import org.apache.excalibur.xml.xpath.XPathProcessor;
041import org.apache.http.Header;
042import org.apache.http.HeaderElement;
043import org.apache.http.HeaderElementIterator;
044import org.apache.http.HttpResponse;
045import org.apache.http.client.config.RequestConfig;
046import org.apache.http.client.methods.CloseableHttpResponse;
047import org.apache.http.client.methods.HttpPost;
048import org.apache.http.client.protocol.HttpClientContext;
049import org.apache.http.conn.ConnectionKeepAliveStrategy;
050import org.apache.http.entity.StringEntity;
051import org.apache.http.impl.client.CloseableHttpClient;
052import org.apache.http.impl.client.HttpClients;
053import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
054import org.apache.http.message.BasicHeaderElementIterator;
055import org.apache.http.protocol.HTTP;
056import org.apache.http.protocol.HttpContext;
057import org.apache.http.util.EntityUtils;
058import org.w3c.dom.Document;
059import org.w3c.dom.Element;
060import org.w3c.dom.Node;
061import org.w3c.dom.NodeList;
062import org.xml.sax.InputSource;
063import org.xml.sax.SAXException;
064
065import org.ametys.core.cache.AbstractCacheManager;
066import org.ametys.core.cache.Cache;
067import org.ametys.core.user.User;
068import org.ametys.core.user.UserIdentity;
069import org.ametys.core.user.UserManager;
070import org.ametys.core.util.JSONUtils;
071import org.ametys.plugins.messagingconnector.AbstractMessagingConnector;
072import org.ametys.plugins.messagingconnector.CalendarEvent;
073import org.ametys.plugins.messagingconnector.EmailMessage;
074import org.ametys.plugins.messagingconnector.MessagingConnectorException;
075import org.ametys.runtime.config.Config;
076import org.ametys.runtime.i18n.I18nizableText;
077
078/**
079 * The connector used by the messaging connector plugin when the zimbra mail
080 * server is used. Implements the methods of the MessagingConnector interface in
081 * order to get the informations from the mail server.<br>
082 */
083public class ZimbraConnector extends AbstractMessagingConnector implements Disposable
084{
085    /** token cache id */
086    public static final String TOKEN_CACHE = ZimbraConnector.class.getName() + "$token";
087
088    /** The number of seconds after what kept alive connections are dropt */
089    protected static final int _DROP_KEPTALIVE_CONNECTION_AFTER = 5;
090    
091    private static final ZimbraPrefixResolver __PREFIX_RESOLVER = new ZimbraPrefixResolver();
092    
093    /** The user manager */
094    protected UserManager _usersManager;
095
096    /** The JSON Utils */
097    protected JSONUtils _jsonUtils;
098
099    /** Url to zimbra */
100    protected String _zimbraUrl;
101
102    /** Preauth secret key */
103    protected String _domainPreauthSecretKey;
104
105    /** Request to the remote app will be ppoled for perfs purposes */
106    protected PoolingHttpClientConnectionManager _connectionManager;
107    
108    /** The keep-alive stragegy to optimize http clients */
109    protected ConnectionKeepAliveStrategy _connectionKeepAliveStrategy;
110    
111    /** The shared configuration for request (for timeout purposes) */
112    protected RequestConfig _connectionConfig;
113    
114    private XPathProcessor _xPathProcessor;
115    private DOMParser _domParser;
116
117    @Override
118    public void service(ServiceManager smanager) throws ServiceException
119    {
120        super.service(smanager);
121        _usersManager = (UserManager) smanager.lookup(UserManager.ROLE);
122        _jsonUtils = (JSONUtils) smanager.lookup(JSONUtils.ROLE);
123        _cacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE);
124        _xPathProcessor = (XPathProcessor) smanager.lookup(XPathProcessor.ROLE);
125        _domParser = (DOMParser) smanager.lookup(DOMParser.ROLE);
126    }
127
128    @Override
129    public void initialize()
130    {
131        super.initialize();
132        _zimbraUrl = StringUtils.removeEnd(Config.getInstance().getValue("zimbra.config.zimbra.baseUrl"), "/");
133        _domainPreauthSecretKey = Config.getInstance().getValue("zimbra.config.preauth.key");
134        
135        int maxSimultaneousConnections = (int) (long) Config.getInstance().getValue("zimbra.config.maxconnections");
136        // Same value for 3 timeouts (socket, connection and response) so one connection can last 3 times this value
137        int connectionTimeout = (int) Math.max(0, (long) Config.getInstance().getValue("zimbra.config.timeout"));  
138        
139        // A pooling connection manager to avoid flooding remote server by reusing existing connections AND by limiting their number
140        _connectionManager = new PoolingHttpClientConnectionManager();
141        _connectionManager.setMaxTotal(maxSimultaneousConnections > 0 ? maxSimultaneousConnections : Integer.MAX_VALUE);
142        _connectionManager.setDefaultMaxPerRoute(maxSimultaneousConnections > 0 ? maxSimultaneousConnections : Integer.MAX_VALUE);
143        
144        // inspired from http://www.baeldung.com/httpclient-connection-management
145        // to keep a connection alive for a few seconds if the remote server did not send back this information
146        _connectionKeepAliveStrategy = new ConnectionKeepAliveStrategy() 
147        {
148            @Override
149            public long getKeepAliveDuration(HttpResponse response, HttpContext context) 
150            {
151                HeaderElementIterator it = new BasicHeaderElementIterator(response.headerIterator(HTTP.CONN_KEEP_ALIVE));
152                while (it.hasNext()) 
153                {
154                    HeaderElement he = it.nextElement();
155                    String param = he.getName();
156                    String value = he.getValue();
157                    if (value != null && param.equalsIgnoreCase("timeout")) 
158                    {
159                        try
160                        {
161                            return Long.parseLong(value) * 1000;
162                        }
163                        catch (NumberFormatException ignore) 
164                        {
165                            // Ignore
166                        }
167                    }
168                }
169                return _DROP_KEPTALIVE_CONNECTION_AFTER * 1000;
170            }
171        };
172        
173        _connectionConfig = RequestConfig.custom()
174                .setConnectTimeout(connectionTimeout * 1000)
175                .setConnectionRequestTimeout(connectionTimeout * 1000) // Time to get an object from the pool
176                .setSocketTimeout(connectionTimeout * 1000).build();
177
178        long tokenDuration = Math.max(0, (long) Config.getInstance().getValue("zimbra.config.tokenDuration")); 
179        
180        _cacheManager.createMemoryCache(TOKEN_CACHE, 
181                new I18nizableText("plugin.zimbra", "PLUGINS_ZIMBRA_CACHE_TOKEN_LABEL"),
182                new I18nizableText("plugin.zimbra", "PLUGINS_ZIMBRA_CACHE_TOKEN_DESCRIPTION"),
183                false,
184                Duration.ofMinutes(tokenDuration));
185    }
186    
187    public void dispose()
188    {
189        _connectionManager.close();
190    }
191    
192    /**
193     * Get a new pooled http client. Do not forget to close it.
194     * @return The client
195     */
196    protected CloseableHttpClient _getHttpClient()
197    {
198        return HttpClients.custom()
199                .setConnectionManager(_connectionManager)
200                .setConnectionManagerShared(true) // avoid automatic pool closing
201                .setKeepAliveStrategy(_connectionKeepAliveStrategy)
202                .setDefaultRequestConfig(_connectionConfig)
203                .build();
204    }
205
206    /**
207     * Preauth user and redirect to the zimbra application
208     * @param redirector The redirector
209     * @param targetApp The zimbra application (ex: mail) 
210     * @throws ProcessingException if failed to redirect
211     * @throws IOException if failed to redirect
212     */
213    public void redirect(Redirector redirector, String targetApp) throws ProcessingException, IOException
214    {
215        UserIdentity identity = _currentUserProvider.getUser();
216        User user = _usersManager.getUser(identity);
217        
218        if (user != null)
219        {
220            String qs = _computeQueryString(user, targetApp);
221            if (qs != null)
222            {
223                redirector.redirect(false, _zimbraUrl + "/service/preauth?" + qs);
224            }
225        }
226    }
227    
228    private String _doPreauthRequest(UserIdentity userIdentity)
229    {
230        User user = _usersManager.getUser(userIdentity);
231        if (user == null)
232        {
233            return null;
234        }
235        
236        String zimbraUser = user.getEmail();
237        
238        if (StringUtils.isEmpty(zimbraUser))
239        {
240            if (getLogger().isDebugEnabled())
241            {
242                getLogger().debug("Cannot retreive zimbra information with empty email for user " + user);
243            }
244            
245            return null;
246        }
247
248        if (getLogger().isDebugEnabled())
249        {
250            getLogger().debug("ZimbraConnector: performing preauth request for user {}", user.getIdentity());
251        }
252        
253        return ZimbraPreauthHelper._doPreauthRequest(_zimbraUrl, zimbraUser, _domainPreauthSecretKey, _getHttpClient());
254    }
255    
256    private String _computeQueryString(User user, String targetApp)
257    {
258        if (user == null)
259        {
260            return null;
261        }
262
263        String zimbraUser = user.getEmail();
264
265        if (StringUtils.isEmpty(zimbraUser))
266        {
267            if (getLogger().isDebugEnabled())
268            {
269                getLogger().debug("Cannot retreive zimbra information with empty email for user " + user);
270            }
271            
272            return null;
273        }
274        
275        return ZimbraPreauthHelper._computeQueryString(zimbraUser, _domainPreauthSecretKey, targetApp);
276    }
277    
278    @Override
279    protected List<CalendarEvent> internalGetEvents(UserIdentity userIdentity, int maxDays, int maxEvents) throws MessagingConnectorException
280    {
281        Document response = _getSoapEvents(userIdentity, maxDays);
282        
283        if (response == null)
284        {
285            return Collections.EMPTY_LIST;
286        }
287        
288        NodeList events = _xPathProcessor.selectNodeList(response, "/soap:Envelope/soap:Body/mail:SearchResponse/mail:appt/mail:inst", __PREFIX_RESOLVER);
289        int count = 0;
290        Map<Long, CalendarEvent> calendarEvents = new TreeMap<>();
291        while (count < events.getLength())
292        {
293            Element item = (Element) events.item(count);
294            
295            String rawStart = _getAttribute(item, "s");
296            long longStart = Long.parseLong(rawStart);
297            Instant startInst = Instant.ofEpochMilli(longStart);
298            Date start = Date.from(startInst);
299            
300            String duration = _getAttribute(item, "dur");
301            Instant endInst = startInst.plusMillis(Long.parseLong(duration));
302            Date end = Date.from(endInst);
303            
304            String name = _getAttribute(item, "name");
305            String location = _getAttribute(item, "loc");
306            
307            CalendarEvent newEvent = new CalendarEvent();
308            newEvent.setStartDate(start);
309            newEvent.setEndDate(end);
310            newEvent.setSubject(name);
311            newEvent.setLocation(location);
312            calendarEvents.put(longStart, newEvent);
313            
314            count++;
315        }
316        
317        return calendarEvents.entrySet().stream().limit(maxEvents).map(e -> e.getValue()).collect(Collectors.toList());
318    }
319
320    private String _getAttribute(Element item, String name)
321    {
322        String value = item.getAttribute(name);
323        if (StringUtils.isEmpty(value))
324        {
325            value = ((Element) item.getParentNode()).getAttribute(name);
326        }
327        
328        return value;
329    }
330
331    @Override
332    protected int internalGetEventsCount(UserIdentity userIdentity, int maxDays) throws MessagingConnectorException
333    {
334        Document response = _getSoapEvents(userIdentity, maxDays);
335        
336        if (response == null)
337        {
338            return 0;
339        }
340        
341        return _xPathProcessor.evaluateAsNumber(response, "count(/soap:Envelope/soap:Body/mail:SearchResponse/mail:appt/mail:inst)", __PREFIX_RESOLVER).intValue();
342    }
343    
344    private Document _getSoapEvents(UserIdentity userIdentity, int maxDays) throws MessagingConnectorException
345    {
346        ZonedDateTime now = ZonedDateTime.now();
347        String soapRequestBody = "<SearchRequest xmlns=\"urn:zimbraMail\" types=\"appointment\" calExpandInstStart=\"" + now.toInstant().toEpochMilli() + "\" calExpandInstEnd =\"" + now.plusDays(maxDays).toInstant().toEpochMilli() + "\" >"
348                               + "  <query>in:calendar</query>"
349                               + "</SearchRequest>";
350
351        return _executeSoapRequest(userIdentity, soapRequestBody, "next events");
352    }
353
354    @Override
355    protected List<EmailMessage> internalGetEmails(UserIdentity userIdentity, int maxEmails) throws MessagingConnectorException
356    {
357        String soapRequestBody = "<SearchRequest xmlns=\"urn:zimbraMail\" types=\"message\" limit=\"" + maxEmails + "\">"
358                               + "  <query>is:unread in:inbox</query>"
359                               + "</SearchRequest>";
360
361        Document document = _executeSoapRequest(userIdentity, soapRequestBody, "unread email");
362        
363        if (document != null)
364        {
365            NodeList emails = _xPathProcessor.selectNodeList(document, "/soap:Envelope/soap:Body/mail:SearchResponse/mail:m", __PREFIX_RESOLVER);
366            
367            List<EmailMessage> result = new ArrayList<>();
368            int count = 0;
369            while (count < emails.getLength())
370            {
371                Node item = emails.item(count);
372     
373                EmailMessage email = new EmailMessage();
374                email.setSender(_xPathProcessor.evaluateAsString(item, "mail:e/@a", __PREFIX_RESOLVER));
375                email.setSubject(_xPathProcessor.evaluateAsString(item, "mail:su", __PREFIX_RESOLVER));
376                email.setSummary(_xPathProcessor.evaluateAsString(item, "mail:fr", __PREFIX_RESOLVER));
377                
378                result.add(email);
379     
380                count++;
381            }
382            
383            return result;
384        }
385        
386        return Collections.emptyList();
387    }
388
389    @Override
390    protected int internalGetEmailsCount(UserIdentity userIdentity) throws MessagingConnectorException
391    {
392        String soapRequestBody = "<GetFolderRequest xmlns=\"urn:zimbraMail\" depth=\"0\">"
393                               + "  <folder path=\"inbox\" />"
394                               + "</GetFolderRequest>";
395        
396        Document document = _executeSoapRequest(userIdentity, soapRequestBody, "email count");
397        if (document != null)
398        {
399            return _xPathProcessor.evaluateAsNumber(document, "/soap:Envelope/soap:Body/mail:GetFolderResponse/mail:folder/@u", __PREFIX_RESOLVER).intValue();
400        }
401        
402        return 0;
403    }
404    
405    private Document _executeSoapRequest(UserIdentity userIdentity, String soapRequestBody, String shortDescription) throws MessagingConnectorException
406    {
407        // Connection to the zimbra mail server
408        String authToken = _getToken(userIdentity);
409        
410        if (StringUtils.isEmpty(authToken))
411        {
412            return null;
413        }
414        
415        String soapRequest = "<soap:Envelope xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\">" 
416                + "  <soap:Header>"
417                + "    <context xmlns=\"urn:zimbra\">"
418                + "       <authToken>" + authToken + "</authToken>" 
419                + "    </context>"
420                + "  </soap:Header>"
421                + "  <soap:Body>"
422                +      soapRequestBody
423                + "  </soap:Body>" 
424                + "</soap:Envelope>";
425        
426        HttpPost req = new HttpPost(_zimbraUrl + "/service/soap");
427        req.setEntity(new StringEntity(soapRequest, StandardCharsets.UTF_8));
428        
429        try (
430                CloseableHttpClient httpClient = _getHttpClient();
431                CloseableHttpResponse response = httpClient.execute(req, HttpClientContext.create())
432            )
433        {
434            int status = response.getStatusLine().getStatusCode();
435            if (status != 200)
436            {
437                getLogger().error("Zimbra failed on calling '{}' with status {} and reason: {}", shortDescription, status, response.getStatusLine().getReasonPhrase());
438                if (getLogger().isDebugEnabled())
439                {
440                    StringBuilder sb = new StringBuilder("Headers:");
441                    for (Header header : response.getAllHeaders())
442                    {
443                        sb.append("\n - ").append(header.getName()).append(": ").append(header.getValue());
444                    }
445                    getLogger().debug(sb.toString());
446                }
447                return null;
448            }
449            
450            String content = EntityUtils.toString(response.getEntity());
451            if (getLogger().isDebugEnabled())
452            {
453                getLogger().debug("{}: {}", userIdentity, content);
454            }
455            Document document = _domParser.parseDocument(new InputSource(new StringReader(content)));
456            
457            String failure = _xPathProcessor.evaluateAsString(document, "/soap:Envelope/soap:Body/soap:Fault/soap:Reason/soap:Text", __PREFIX_RESOLVER);
458            
459            if (StringUtils.isEmpty(failure))
460            {
461                return document;
462            }
463            else
464            {
465                getLogger().warn("Zimbra failed to return {} for user {}: {}", shortDescription, userIdentity, failure);
466                return null;
467            }
468        }
469        catch (IOException | SAXException e)
470        {
471            throw new MessagingConnectorException("Failed to get Zimbra " + shortDescription + " for user " + userIdentity, MessagingConnectorException.ExceptionType.UNKNOWN, e);
472        }
473    }
474    
475    private static class ZimbraPrefixResolver implements PrefixResolver
476    {
477        private Map<String, String> _namespaces = Map.of("soap", "http://www.w3.org/2003/05/soap-envelope",
478                                                         "mail", "urn:zimbraMail");
479        
480        public String prefixToNamespace(String prefix)
481        {
482            return _namespaces.get(prefix);
483        }
484    }
485
486    private String _getToken (UserIdentity user)
487    {
488        if (user == null)
489        {
490            return null;
491        }
492         
493        Cache<UserIdentity, String> cache = _getCache();
494        
495        return cache.get(user, key -> _doPreauthRequest(key));
496    }
497    
498    private Cache<UserIdentity, String> _getCache() 
499    {
500        return this._cacheManager.get(TOKEN_CACHE);
501    }    
502}