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