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}