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.glpi;
017
018import java.io.ByteArrayOutputStream;
019import java.io.IOException;
020import java.io.InputStream;
021import java.net.URI;
022import java.net.URISyntaxException;
023import java.util.ArrayList;
024import java.util.Collections;
025import java.util.HashMap;
026import java.util.List;
027import java.util.Map;
028import java.util.concurrent.Callable;
029import java.util.concurrent.ExecutionException;
030import java.util.concurrent.TimeUnit;
031import java.util.stream.Collectors;
032
033import org.apache.avalon.framework.activity.Initializable;
034import org.apache.avalon.framework.component.Component;
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.avalon.framework.service.Serviceable;
038import org.apache.commons.io.IOUtils;
039import org.apache.commons.lang3.StringUtils;
040import org.apache.http.client.config.RequestConfig;
041import org.apache.http.client.methods.CloseableHttpResponse;
042import org.apache.http.client.methods.HttpGet;
043import org.apache.http.client.utils.URIBuilder;
044import org.apache.http.impl.client.CloseableHttpClient;
045import org.apache.http.impl.client.HttpClientBuilder;
046
047import org.ametys.core.user.UserIdentity;
048import org.ametys.core.util.JSONUtils;
049import org.ametys.runtime.config.Config;
050import org.ametys.runtime.i18n.I18nizableText;
051import org.ametys.runtime.plugin.component.AbstractLogEnabled;
052import org.ametys.runtime.plugin.component.PluginAware;
053
054import com.google.common.cache.Cache;
055import com.google.common.cache.CacheBuilder;
056import com.google.common.cache.CacheLoader;
057import com.google.common.cache.LoadingCache;
058
059/**
060 * Connection and information from GLPI webservice
061 */
062public class TicketGlpiManager extends AbstractLogEnabled implements Component, Initializable, Serviceable, PluginAware
063{
064    /** Avalon ROLE. */
065    public static final String ROLE = TicketGlpiManager.class.getName();
066
067    private static final String __GLPI_INIT_SESSION = "/initSession/";
068
069    private static final String __GLPI_SEARCH_USERS = "/search/user/";
070
071    private static final String __GLPI_SEARCH_TICKET = "/search/ticket/";
072
073    private static final String __GLPI_KILL_SESSION = "/killSession/";
074
075    /** Maximum cache size, in number of records. */
076    protected long _maxCacheSize;
077
078    /** The cache TTL in minutes. */
079    protected long _cacheTtl;
080
081    /**
082     * The user information cache. The key of the cache is the user identity it self
083     */
084    protected LoadingCache<UserIdentity, Map<String, Object>> _cache;
085
086    /**
087     * The user information cache. The key of the cache is the user identity it self
088     */
089    protected Cache<String, Integer> _cacheIdentities;
090
091    private JSONUtils _jsonUtils;
092
093    private Map<Integer, I18nizableText> _glpiStatus;
094    private Map<Integer, I18nizableText> _glpiType;
095
096    private String _pluginName;
097
098    public void setPluginInfo(String pluginName, String featureName, String id)
099    {
100        _pluginName = pluginName;
101    }
102    
103    @Override
104    public void service(ServiceManager manager) throws ServiceException
105    {
106        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
107    }
108
109    @Override
110    public void initialize()
111    {
112        // Tickets cache
113        GlpiCacheLoader loader = new GlpiCacheLoader();
114
115        Long maxCacheSizeConf = Config.getInstance().getValue("org.ametys.plugins.glpi.maxsize");
116        Long maxCacheSize = (long) (maxCacheSizeConf != null ? maxCacheSizeConf.intValue() : 1000);
117
118        Long cacheTtlConf = Config.getInstance().getValue("org.ametys.plugins.glpi.ttl");
119        Long cacheTtl = (long) (cacheTtlConf != null && cacheTtlConf.intValue() >= 0 ? cacheTtlConf.intValue() : 60);
120
121        CacheBuilder<Object, Object> cacheBuilder = CacheBuilder.newBuilder().expireAfterWrite(cacheTtl, TimeUnit.MINUTES);
122
123        if (maxCacheSize > 0)
124        {
125            cacheBuilder.maximumSize(maxCacheSize);
126        }
127
128        _cache = cacheBuilder.build(loader);
129
130        // Identities cache
131        maxCacheSizeConf = Config.getInstance().getValue("org.ametys.plugins.glpi.maxsize.identities");
132        maxCacheSize = (long) (maxCacheSizeConf != null ? maxCacheSizeConf.intValue() : 5000);
133
134        cacheTtlConf = Config.getInstance().getValue("org.ametys.plugins.glpi.ttl.identities");
135        cacheTtl = (long) (cacheTtlConf != null && cacheTtlConf.intValue() >= 0 ? cacheTtlConf.intValue() : 60);
136
137        CacheBuilder<Object, Object> cacheIdentitiesBuilder = CacheBuilder.newBuilder().expireAfterWrite(cacheTtl, TimeUnit.MINUTES);
138
139        if (maxCacheSize > 0)
140        {
141            cacheIdentitiesBuilder.maximumSize(maxCacheSize);
142        }
143
144        _cacheIdentities = cacheIdentitiesBuilder.build();
145    }
146
147    /**
148     * Get the user collaboration information from the exchange server.
149     * 
150     * @param userIdentity the user identity.
151     * @return the user collaboration information as a Map.
152     * @throws Exception If an error occurred
153     */
154    protected Map<String, Object> loadUserInfo(UserIdentity userIdentity) throws Exception
155    {
156        Map<String, Object> userInfo = new HashMap<>();
157
158        String glpiUrl = Config.getInstance().getValue("org.ametys.plugins.glpi.url");
159        String usertoken = Config.getInstance().getValue("org.ametys.plugins.glpi.usertoken");
160        String apptoken = Config.getInstance().getValue("org.ametys.plugins.glpi.apptoken");
161
162        if (glpiUrl == null || usertoken == null || apptoken == null)
163        {
164            if (getLogger().isWarnEnabled())
165            {
166                getLogger().warn("Missing configuration: unable to contact the GLPI WebService Rest API, the configuration is incomplete.");
167            }
168
169            return userInfo;
170        }
171
172        RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(10000).setSocketTimeout(10000).build();
173
174        try (CloseableHttpClient httpclient = HttpClientBuilder.create().setDefaultRequestConfig(requestConfig).useSystemProperties().build())
175        {
176            String sessionToken = _getGlpiSessionToken(httpclient, glpiUrl, usertoken, apptoken);
177            if (sessionToken == null)
178            {
179                return userInfo;
180            }
181
182            try
183            {
184                Integer userId = _cacheIdentities.get(userIdentity.getLogin().toLowerCase(), new Callable<Integer>()
185                {
186                    @Override
187                    public Integer call() throws Exception
188                    {
189                        return getUserIdentity(httpclient, userIdentity, glpiUrl, sessionToken, apptoken);
190                    }
191                });
192
193                if (userId != null && userId != -1)
194                {
195                    Map<String, Object> glpiOpenTickets = getGlpiTickets(httpclient, glpiUrl, sessionToken, apptoken, userId);
196                    if (glpiOpenTickets != null && !glpiOpenTickets.isEmpty())
197                    {
198                        userInfo.put("countOpenTickets", glpiOpenTickets.get("countOpenTickets"));
199                        userInfo.put("openTickets", glpiOpenTickets.get("openTickets"));
200                    }
201                }
202                else
203                {
204                    getLogger().debug("GPLI identity not found for user {}", userIdentity);
205                }
206            }
207            finally
208            {
209                _killGlpiSessionToken(httpclient, glpiUrl, sessionToken, apptoken);
210            }
211        }
212
213        return userInfo;
214    }
215
216    private String _getGlpiSessionToken(CloseableHttpClient httpclient, String glpiUrl, String usertoken, String apptoken) throws IOException, URISyntaxException
217    {
218        URIBuilder builder = new URIBuilder(glpiUrl + __GLPI_INIT_SESSION).addParameter("user_token", usertoken).addParameter("app_token", apptoken);
219        
220        if (getLogger().isDebugEnabled())
221        {
222            getLogger().debug("Call GLPI webservice to init session : " + builder.build());
223        }
224        
225        Map<String, Object> jsonObject = _callWebServiceApi(httpclient, builder.build(), true);
226        if (jsonObject != null && jsonObject.containsKey("session_token"))
227        {
228            String token = (String) jsonObject.get("session_token");
229            if (getLogger().isDebugEnabled())
230            {
231                getLogger().debug("GPLI WS returned session token '" + token + "'");
232            }
233            return token;
234        }
235        
236        if (getLogger().isDebugEnabled())
237        {
238            getLogger().debug("GPLI WS returned no session token for user token '" + usertoken + "'");
239        }
240
241        return null;
242    }
243
244    /**
245     * Get the user identity and fill the user identities cache
246     * 
247     * @param httpclient The http client to send a request to the webservice
248     * @param userIdentity The current user identity
249     * @param glpiUrl The GLPI Url
250     * @param sessionToken The session token
251     * @param apptoken The app token
252     * @return The user identity corresponding, or null if not found
253     * @throws Exception if an error occurred
254     */
255    @SuppressWarnings("unchecked")
256    protected Integer getUserIdentity(CloseableHttpClient httpclient, UserIdentity userIdentity, String glpiUrl, String sessionToken, String apptoken) throws Exception
257    {
258        Long maxIdentitiesSize = Config.getInstance().getValue("org.ametys.plugins.glpi.maxsize.identities");
259        URIBuilder builder = new URIBuilder(glpiUrl + __GLPI_SEARCH_USERS)
260                .addParameter("range", "0-" + (maxIdentitiesSize != null ? maxIdentitiesSize : 1000))
261                .addParameter("forcedisplay[0]", "1")
262                .addParameter("forcedisplay[1]", "2")
263                .addParameter("criteria[0][field]", "8")
264                .addParameter("criteria[0][searchtype]", "contains")
265                .addParameter("criteria[0][value]", "1")
266                .addParameter("session_token", sessionToken)
267                .addParameter("app_token", apptoken);
268        Map<String, Object> jsonObject = _callWebServiceApi(httpclient, builder.build(), true);
269        if (jsonObject != null && jsonObject.containsKey("data"))
270        {
271            List<Map<String, Object>> data = (List<Map<String, Object>>) jsonObject.get("data");
272            Map<String, Integer> userIdentities = data.stream().filter(user -> StringUtils.isNotEmpty((String) user.get("1")) && user.containsKey("2")).collect(
273                    Collectors.toMap(user -> ((String) user.get("1")).toLowerCase(), user -> (Integer) user.get("2")));
274            _cacheIdentities.putAll(userIdentities);
275            
276            if (userIdentities.containsKey(userIdentity.getLogin().toLowerCase()))
277            {
278                return userIdentities.get(userIdentity.getLogin().toLowerCase());
279            }
280        }
281        return -1;
282    }
283
284    /**
285     * Get the GLPI tickets
286     * @param httpclient the HTTP client
287     * @param glpiUrl The url of GLPI server
288     * @param sessionToken The session token
289     * @param apptoken The app token
290     * @param userId The user id
291     * @return The tickets
292     * @throws IOException if an error occurred
293     * @throws URISyntaxException if failed to build uri
294     */
295    protected Map<String, Object> getGlpiTickets(CloseableHttpClient httpclient, String glpiUrl, String sessionToken, String apptoken, Integer userId) throws IOException, URISyntaxException
296    {
297        StringBuilder uri = new StringBuilder(glpiUrl + __GLPI_SEARCH_TICKET + "?");
298        
299        uri.append(getTicketSearchQuery(userId))
300            .append("&session_token=").append(sessionToken)
301            .append("&app_token=").append(apptoken);
302        
303        if (getLogger().isDebugEnabled())
304        {
305            getLogger().debug("Call GLPI webservice to search tickets : " + uri.toString());
306        }
307        
308        Map<String, Object> jsonObject = _callWebServiceApi(httpclient, new URI(uri.toString()), true);
309        if (jsonObject != null)
310        {
311            Map<String, Object> glpiTicketsInfo = new HashMap<>();
312            if (jsonObject.containsKey("totalcount"))
313            {
314                glpiTicketsInfo.put("countOpenTickets", jsonObject.get("totalcount"));
315            }
316            if (jsonObject.containsKey("data"))
317            {
318                Object dataObject = jsonObject.get("data");
319                List<GlpiTicket> glpiTickets = new ArrayList<>();
320                if (dataObject instanceof List)
321                {
322                    @SuppressWarnings("unchecked")
323                    List<Object> dataList = (List<Object>) dataObject;
324                    for (Object object : dataList)
325                    {
326                        if (object instanceof Map)
327                        {
328                            @SuppressWarnings("unchecked")
329                            Map<String, Object> ticketData = (Map<String, Object>) object;
330                            glpiTickets.add(parseTicket(ticketData));
331                        }
332                    }
333                    glpiTicketsInfo.put("openTickets", glpiTickets);
334                }
335            }
336            return glpiTicketsInfo;
337        }
338        return null;
339    }
340    
341    /**
342     * Parse data into {@link GlpiTicket}
343     * @param data the json data
344     * @return the {@link GlpiTicket}
345     */
346    protected GlpiTicket parseTicket(Map<String, Object> data)
347    {
348        int ticketId = (int) data.get("2");
349        String ticketTitle = (String) data.get("1");
350        int status = (int) data.get("12");
351        
352        int type = data.containsKey("14") ? (int) data.get("14") : -1;
353        String category = (String) data.get("7");
354        
355        GlpiTicket ticket = new GlpiTicket(ticketId, ticketTitle, status, type, category);
356        return ticket;
357    }
358    
359    /**
360     * Get the part of rest API url for tickets search
361     * @param userId The user id
362     * @return The search query to concat to rest API url
363     */
364    protected String getTicketSearchQuery(Integer userId)
365    {
366        StringBuilder sb = new StringBuilder();
367        
368        // forcedisplay is used for data to return
369        
370        // To get all available search options, call GLPI_URL/listSearchOptions/Ticket?session_token=...
371        
372        sb.append("forcedisplay[0]=1") // title
373            .append("&forcedisplay[1]=12") // status
374            .append("&forcedisplay[2]=4") // user id
375            .append("&forcedisplay[3]=2") // ticket id
376            .append("&forcedisplay[4]=7") // category
377            .append("&forcedisplay[6]=14") // type
378            .append("&criteria[0][field]=12&criteria[0][searchtype]=0&criteria[0][value]=notold") // status=notold (unresolved)
379            .append("&criteria[1][link]=AND")
380            .append("&criteria[1][field]=4&criteria[1][searchtype]=equals&criteria[1][value]=" + userId) // current user is the creator
381            .append("&sort=4"); // sort
382        
383        // Get tickets with unresolved status (notold)
384        return sb.toString();
385    }
386
387    private void _killGlpiSessionToken(CloseableHttpClient httpclient, String glpiUrl, String sessionToken, String apptoken) throws IOException, URISyntaxException
388    {
389        URIBuilder builder = new URIBuilder(glpiUrl + __GLPI_KILL_SESSION).addParameter("session_token", sessionToken).addParameter("app_token", apptoken);
390        _callWebServiceApi(httpclient, builder.build(), false);
391    }
392
393    private Map<String, Object> _callWebServiceApi(CloseableHttpClient httpclient, URI uri, boolean getJsonObject) throws IOException
394    {
395        HttpGet get = new HttpGet(uri);
396
397        try (CloseableHttpResponse httpResponse = httpclient.execute(get))
398        {
399            if (!_isSuccess(httpResponse.getStatusLine().getStatusCode()))
400            {
401                String msg = null;
402                if (httpResponse.getEntity() != null)
403                {
404                    ByteArrayOutputStream bos = new ByteArrayOutputStream();
405                    try (InputStream is = httpResponse.getEntity().getContent())
406                    {
407                        IOUtils.copy(is, bos);
408                    }
409                    
410                    msg = bos.toString("UTF-8");
411                }
412                
413                getLogger().error("An error occurred while contacting the GLPI Rest API (status code : " + httpResponse.getStatusLine().getStatusCode() + "). Response is : " + msg);
414                return null;
415            }
416
417            if (getJsonObject)
418            {
419                ByteArrayOutputStream bos = new ByteArrayOutputStream();
420                try (InputStream is = httpResponse.getEntity().getContent())
421                {
422                    IOUtils.copy(is, bos);
423                }
424
425                if (getLogger().isDebugEnabled())
426                {
427                    getLogger().debug("GLPI webservice at uri " + uri + " returned : " + bos.toString("UTF-8"));
428                }
429                
430                return _jsonUtils.convertJsonToMap(bos.toString("UTF-8"));
431            }
432        }
433
434        return null;
435    }
436
437    private boolean _isSuccess(int statusCode)
438    {
439        return statusCode >= 200 && statusCode < 300;
440    }
441
442    /**
443     * Get the number of open tickets
444     * 
445     * @param userIdentity The user identity
446     * @return the number of unread mail
447     * @throws ExecutionException If an error occured during the connection to
448     *             the server
449     */
450    public int getCountOpenTickets(UserIdentity userIdentity) throws ExecutionException
451    {
452        if (userIdentity == null)
453        {
454            throw new IllegalArgumentException("User is not connected");
455        }
456        
457        Map<String, Object> userInfo = _cache.get(userIdentity);
458
459        if (userInfo.get("countOpenTickets") != null)
460        {
461            return (Integer) userInfo.get("countOpenTickets");
462        }
463        else
464        {
465            return -1;
466            //throw new IllegalArgumentException("Unable to get open tickets count. No user matches in GPLI with login " + userIdentity.getLogin());
467        }
468    }
469
470    /**
471     * Get all information about open tickets
472     * 
473     * @param userIdentity The user identity
474     * @return List of all information about open tickets
475     * @throws ExecutionException If an error occured during the connection to
476     *             the server
477     */
478    @SuppressWarnings("unchecked")
479    public List<GlpiTicket> getOpenTickets(UserIdentity userIdentity) throws ExecutionException
480    {
481        if (userIdentity == null)
482        {
483            throw new IllegalArgumentException("User is not connected");
484        }
485        
486        Map<String, Object> userInfo = _cache.get(userIdentity);
487        if (userInfo.get("openTickets") != null)
488        {
489            return (List<GlpiTicket>) userInfo.get("openTickets");
490        }
491        else
492        {
493            return Collections.EMPTY_LIST;
494            //throw new IllegalArgumentException("Unable to get open tickets. No user matches in GPLI with login " + userIdentity.getLogin());
495        }
496    }
497
498    /**
499     * Get the i18n label of a GLPI status
500     * @param status The GLPI status
501     * @return the label of status as {@link I18nizableText}
502     */
503    public I18nizableText getGlpiStatusLabel(int status)
504    {
505        if (_glpiStatus == null)
506        {
507            _glpiStatus = new HashMap<>();
508            _glpiStatus.put(1, new I18nizableText("plugin." + _pluginName, "PLUGINS_GLPI_INCOMMING_STATUS"));
509            _glpiStatus.put(2, new I18nizableText("plugin." + _pluginName, "PLUGINS_GLPI_ASSIGNED_STATUS"));
510            _glpiStatus.put(3, new I18nizableText("plugin." + _pluginName, "PLUGINS_GLPI_PLANNED_STATUS"));
511            _glpiStatus.put(4, new I18nizableText("plugin." + _pluginName, "PLUGINS_GLPI_WAITING_STATUS"));
512            _glpiStatus.put(5, new I18nizableText("plugin." + _pluginName, "PLUGINS_GLPI_SOLVED_STATUS"));
513            _glpiStatus.put(6, new I18nizableText("plugin." + _pluginName, "PLUGINS_GLPI_CLOSED_STATUS"));
514            
515        }
516        return _glpiStatus.get(Integer.valueOf(status));
517    }
518    
519    /**
520     * Get the i18n label of a GLPI ticket type
521     * @param type The GLPI type
522     * @return the label of status as {@link I18nizableText}
523     */
524    public I18nizableText getGlpiTypeLabel(int type)
525    {
526        if (_glpiType == null)
527        {
528            _glpiType = new HashMap<>();
529            _glpiType.put(1, new I18nizableText("plugin." + _pluginName, "PLUGINS_GLPI_INCIDENT_TYPE"));
530            _glpiType.put(2, new I18nizableText("plugin." + _pluginName, "PLUGINS_GLPI_DEMAND_TYPE"));
531            
532        }
533        return _glpiType.get(Integer.valueOf(type));
534    }
535
536    /**
537     * The Glpi cache loader.
538     */
539    protected class GlpiCacheLoader extends CacheLoader<UserIdentity, Map<String, Object>>
540    {
541        @Override
542        public Map<String, Object> load(UserIdentity userIdentity) throws Exception
543        {
544            return loadUserInfo(userIdentity);
545        }
546    }
547}