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