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}