001/* 002 * Copyright 2024 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.rocket.chat; 017 018import java.io.ByteArrayInputStream; 019import java.io.IOException; 020import java.io.InputStream; 021import java.nio.charset.StandardCharsets; 022import java.time.Duration; 023import java.time.ZonedDateTime; 024import java.util.ArrayList; 025import java.util.Base64; 026import java.util.Base64.Decoder; 027import java.util.Base64.Encoder; 028import java.util.Collection; 029import java.util.Collections; 030import java.util.HashMap; 031import java.util.HashSet; 032import java.util.LinkedHashMap; 033import java.util.List; 034import java.util.Map; 035import java.util.Map.Entry; 036import java.util.Objects; 037import java.util.Optional; 038import java.util.Set; 039import java.util.stream.Collectors; 040 041import org.apache.avalon.framework.activity.Disposable; 042import org.apache.avalon.framework.activity.Initializable; 043import org.apache.avalon.framework.component.Component; 044import org.apache.avalon.framework.context.Context; 045import org.apache.avalon.framework.context.ContextException; 046import org.apache.avalon.framework.context.Contextualizable; 047import org.apache.avalon.framework.service.ServiceException; 048import org.apache.avalon.framework.service.ServiceManager; 049import org.apache.avalon.framework.service.Serviceable; 050import org.apache.cocoon.components.ContextHelper; 051import org.apache.cocoon.environment.Request; 052import org.apache.commons.codec.digest.Sha2Crypt; 053import org.apache.commons.lang3.StringUtils; 054import org.apache.commons.lang3.Strings; 055import org.apache.commons.lang3.tuple.Pair; 056import org.apache.excalibur.source.Source; 057import org.apache.excalibur.source.SourceResolver; 058import org.apache.hc.client5.http.classic.methods.HttpGet; 059import org.apache.hc.client5.http.classic.methods.HttpPost; 060import org.apache.hc.client5.http.entity.mime.HttpMultipartMode; 061import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; 062import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; 063import org.apache.hc.core5.http.ClassicHttpRequest; 064import org.apache.hc.core5.http.ContentType; 065import org.apache.hc.core5.http.HttpEntity; 066import org.apache.hc.core5.http.io.entity.EntityUtils; 067import org.apache.hc.core5.http.io.entity.StringEntity; 068import org.apache.hc.core5.io.CloseMode; 069import org.apache.poi.util.IOUtils; 070import org.apache.tika.Tika; 071 072import org.ametys.core.authentication.AuthenticateAction; 073import org.ametys.core.cache.AbstractCacheManager; 074import org.ametys.core.cache.Cache; 075import org.ametys.core.ui.Callable; 076import org.ametys.core.user.CurrentUserProvider; 077import org.ametys.core.user.User; 078import org.ametys.core.user.UserIdentity; 079import org.ametys.core.user.UserManager; 080import org.ametys.core.user.population.PopulationContextHelper; 081import org.ametys.core.userpref.UserPreferencesException; 082import org.ametys.core.userpref.UserPreferencesManager; 083import org.ametys.core.util.CryptoHelper; 084import org.ametys.core.util.DateUtils; 085import org.ametys.core.util.HttpUtils; 086import org.ametys.core.util.JSONUtils; 087import org.ametys.core.util.LambdaUtils; 088import org.ametys.core.util.URIUtils; 089import org.ametys.runtime.config.Config; 090import org.ametys.runtime.i18n.I18nizableText; 091import org.ametys.runtime.plugin.component.AbstractLogEnabled; 092import org.ametys.web.WebHelper; 093import org.ametys.web.transformation.xslt.AmetysXSLTHelper; 094 095/** 096 * Helper for the rocket.chat link 097 */ 098public class RocketChatHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable, Initializable, Disposable 099{ 100 /** The Avalon role */ 101 public static final String ROLE = RocketChatHelper.class.getName(); 102 103 private static final String __USERPREF_PREF_PASSWORD = "rocket.chat-connector-password"; 104 private static final String __USERPREF_PREF_TOKEN = "rocket.chat-connector-token"; 105 private static final String __USERPREF_PREF_ID = "rocket.chat-connector-id"; 106 private static final String __USERPREF_CONTEXT = "/rocket.chat-connector"; 107 108 private static final String __CONFIG_ADMIN_ID = "rocket.chat.rocket.admin.id"; 109 private static final String __CONFIG_ADMIN_TOKEN = "rocket.chat.rocket.admin.token"; 110 private static final String __CONFIG_URL = "rocket.chat.rocket.url"; 111 112 private static final String __CACHE_STATUS = RocketChatHelper.class.getName() + "$status"; 113 private static final int __CACHE_STATUS_DURATION = 120; 114 private static final String __CACHE_UPDATES = RocketChatHelper.class.getName() + "$updates"; 115 private static final int __CACHE_UPDATES_DURATION = 120; 116 117 private static final int __MAX_CONNECTION = 10; 118 private static final int __CONNECTION_TIMEOUT = 30; 119 120 private static final Encoder __BASE64_ENCODER = Base64.getUrlEncoder().withoutPadding(); 121 private static final Decoder __BASE64_DECODER = Base64.getUrlDecoder(); 122 123 /** JSON Utils */ 124 protected JSONUtils _jsonUtils; 125 126 /** User Manager */ 127 protected UserManager _userManager; 128 129 /** User Preferences */ 130 protected UserPreferencesManager _userPreferencesManager; 131 132 /** Cryptography */ 133 protected CryptoHelper _cryptoHelper; 134 135 /** Current user provider */ 136 protected CurrentUserProvider _currentUserProvider; 137 138 private SourceResolver _sourceResolver; 139 140 private Context _context; 141 142 private AbstractCacheManager _cacheManager; 143 144 private PopulationContextHelper _populationContextHelper; 145 146 private CloseableHttpClient _httpClient; 147 148 public void contextualize(Context context) throws ContextException 149 { 150 _context = context; 151 } 152 153 public void service(ServiceManager manager) throws ServiceException 154 { 155 _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE); 156 _userManager = (UserManager) manager.lookup(UserManager.ROLE); 157 _userPreferencesManager = (UserPreferencesManager) manager.lookup(UserPreferencesManager.ROLE); 158 _cryptoHelper = (CryptoHelper) manager.lookup("org.ametys.plugins.rocket.chat.cryptoHelper"); 159 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 160 _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 161 _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE); 162 _populationContextHelper = (PopulationContextHelper) manager.lookup(PopulationContextHelper.ROLE); 163 } 164 165 public void initialize() throws Exception 166 { 167 _createCaches(); 168 169 _httpClient = HttpUtils.createHttpClient(__MAX_CONNECTION, __CONNECTION_TIMEOUT, false); 170 } 171 172 public void dispose() 173 { 174 _httpClient.close(CloseMode.GRACEFUL); 175 } 176 177 /** 178 * Creates the caches 179 */ 180 protected void _createCaches() 181 { 182 _cacheManager.createMemoryCache(__CACHE_STATUS, 183 new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_CACHE_LABEL"), 184 new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_CACHE_DESC"), 185 true, 186 Duration.ofSeconds(__CACHE_STATUS_DURATION)); 187 _cacheManager.createMemoryCache(__CACHE_UPDATES, 188 new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_UPDATES_LABEL"), 189 new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_UPDATES_DESC"), 190 true, 191 Duration.ofSeconds(__CACHE_UPDATES_DURATION)); 192 } 193 194 private Cache<UserIdentity, String> _getStatusCache() 195 { 196 return _cacheManager.get(__CACHE_STATUS); 197 } 198 199 private Cache<UserIdentity, Boolean> _getUpdatesCache() 200 { 201 return _cacheManager.get(__CACHE_UPDATES); 202 } 203 204 private Map<String, Object> _doGet(String api, Map<String, String> parameters) throws IOException 205 { 206 return _doGet(api, parameters, Config.getInstance().getValue(__CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(__CONFIG_ADMIN_ID)); 207 } 208 209 private Map<String, Object> _doGet(String api, Map<String, String> parameters, String authToken, String userId) throws IOException 210 { 211 String path = Config.getInstance().getValue(__CONFIG_URL) + "/api/" + api; 212 213 String uri = URIUtils.encodeURI(path, parameters); 214 HttpGet request = new HttpGet(uri); 215 request.setHeader("Content-Type", "application/json"); 216 217 return _execRequest(request, authToken, userId); 218 } 219 220 private Map<String, Object> _doPOST(String api, Map<String, Object> parameters) throws IOException 221 { 222 return _doPOST(api, parameters, Config.getInstance().getValue(__CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(__CONFIG_ADMIN_ID)); 223 } 224 225 private Map<String, Object> _doPOST(String api, Map<String, Object> parameters, String authToken, String userId) throws IOException 226 { 227 String path = Config.getInstance().getValue(__CONFIG_URL) + "/api/" + api; 228 229 HttpPost request = new HttpPost(path); 230 231 String json = _jsonUtils.convertObjectToJson(parameters); 232 request.setEntity(new StringEntity(json, ContentType.create("application/json", StandardCharsets.UTF_8))); 233 request.setHeader("Content-Type", "application/json"); 234 235 return _execRequest(request, authToken, userId); 236 } 237 238 private Map<String, Object> _doMultipartPOST(String api, Map<String, Object> parameters) throws IOException 239 { 240 return _doMultipartPOST(api, parameters, Config.getInstance().getValue(__CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(__CONFIG_ADMIN_ID)); 241 } 242 243 private Map<String, Object> _doMultipartPOST(String api, Map<String, Object> parameters, String authToken, String userId) throws IOException 244 { 245 String path = Config.getInstance().getValue(__CONFIG_URL) + "/api/" + api; 246 247 HttpPost request = new HttpPost(path); 248 249 MultipartEntityBuilder builder = MultipartEntityBuilder.create(); 250 builder.setMode(HttpMultipartMode.EXTENDED); 251 252 for (Entry<String, Object> p : parameters.entrySet()) 253 { 254 if (p.getValue() instanceof String) 255 { 256 builder.addTextBody(p.getKey(), (String) p.getValue(), ContentType.create("text/plain", StandardCharsets.UTF_8)); 257 } 258 else if (p.getValue() instanceof InputStream is) 259 { 260 byte[] imageAsBytes = IOUtils.toByteArray(is); 261 ByteArrayInputStream bis = new ByteArrayInputStream(imageAsBytes); 262 Tika tika = new Tika(); 263 String mimeType = tika.detect(imageAsBytes); 264 265 builder.addBinaryBody(p.getKey(), bis, ContentType.create(mimeType), p.getKey()); 266 } 267 else 268 { 269 throw new UnsupportedOperationException("Cannot post the type " + p.getValue().getClass().getName() + " for parameter " + p.getKey()); 270 } 271 } 272 273 HttpEntity multipart = builder.build(); 274 request.setEntity(multipart); 275 276 return _execRequest(request, authToken, userId); 277 } 278 279 private Map<String, Object> _execRequest(ClassicHttpRequest request, String authToken, String userId) throws IOException 280 { 281 getLogger().debug("Request to Rocket.Chat server {}", request.getRequestUri()); 282 283 request.setHeader("X-Auth-Token", authToken); 284 request.setHeader("X-User-Id", userId); 285 return _httpClient.execute(request, response -> _jsonUtils.convertJsonToMap(EntityUtils.toString(response.getEntity()))); 286 } 287 288 private String _getError(Map<String, Object> info) 289 { 290 if (info.containsKey("error")) 291 { 292 return (String) info.get("error"); 293 } 294 else if (info.containsKey("message")) 295 { 296 return (String) info.get("message"); 297 } 298 else 299 { 300 return ""; 301 } 302 } 303 304 /** 305 * Get (or create) a new user. 306 * @param userIdentity the user that will be mirrored into chat 307 * @param updateIfNotNew If the user was already existing, should it be updated (except password)? 308 * @return the user info or null if user does not exist in chat and create was not required 309 * @throws IOException something went wrong 310 * @throws UserPreferencesException error while reading the user preferences 311 * @throws InterruptedException error while reading the user preferences 312 */ 313 @SuppressWarnings("unchecked") 314 public Map<String, Object> getUser(UserIdentity userIdentity, boolean updateIfNotNew) throws IOException, UserPreferencesException, InterruptedException 315 { 316 User user = _userManager.getUser(userIdentity); 317 if (user == null) 318 { 319 throw new IllegalStateException("Cannot create user in Rocket.Chat for unexisting user " + UserIdentity.userIdentityToString(userIdentity)); 320 } 321 322 Map<String, Object> userInfo = _doGet("v1/users.info", Map.of("username", _userIdentitytoUserName(userIdentity))); 323 if (!_isOperationSuccessful(userInfo)) 324 { 325 Map<String, String> ametysUserInfo = getAmetysUserInfo(user, true, 64); 326 String userName = ametysUserInfo.get("userName"); 327 String userEmail = ametysUserInfo.get("userEmail"); 328 329 userInfo = _doPOST("v1/users.create", Map.of("username", _userIdentitytoUserName(userIdentity), 330 "email", userEmail, 331 "name", userName, 332 "verified", true, 333 "password", _getUserPassword(userIdentity))); 334 if (!_isOperationSuccessful(userInfo)) 335 { 336 throw new IllegalStateException("Cannot create user in Rocket.Chat for " + UserIdentity.userIdentityToString(userIdentity) + ": " + _getError(userInfo)); 337 } 338 339 getLogger().debug("User " + UserIdentity.userIdentityToString(userIdentity) + " created on the chat server"); 340 341 _updateAvatar(userIdentity); 342 _getUpdatesCache().put(userIdentity, true); 343 } 344 else if (_getUpdatesCache().get(userIdentity) == null) // Lets avoid calling updateUserInfos too often since there is an unavoidable 60 seconds rate limit ROCKETCHAT-4 345 { 346 Map<String, Object> userMap = (Map<String, Object>) userInfo.get("user"); 347 _userPreferencesManager.addUserPreference(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_ID, (String) userMap.get("_id")); 348 349 updateUserInfos(userIdentity, false); 350 _getUpdatesCache().put(userIdentity, true); 351 352 return userMap; 353 } 354 355 Map<String, Object> userMap = (Map<String, Object>) userInfo.get("user"); 356 _userPreferencesManager.addUserPreference(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_ID, (String) userMap.get("_id")); 357 358 return userMap; 359 } 360 361 /** 362 * Get the user info 363 * @param user The user 364 * @param externalUrl Is the image url external? 365 * @param imageSize The size of the avatar 366 * @return The name, email and avatar 367 */ 368 public Map<String, String> getAmetysUserInfo(User user, boolean externalUrl, int imageSize) 369 { 370 return Map.of("userName", user.getFullName(), 371 "userSortableName", user.getSortableName(), 372 "userEmail", user.getEmail(), 373 "userAvatar", externalUrl ? AmetysXSLTHelper.uriPrefix() + "/_plugins/core-ui/user/" + user.getIdentity().getPopulationId() + "/" + URIUtils.encodePath(user.getIdentity().getLogin()) + "/image_" + imageSize + "?lang=en" // TODO get the user pref of language? The purpose is to always get the same avatar in every languages since it is shared in RC 374 : "cocoon://_plugins/core-ui/user/" + user.getIdentity().getPopulationId() + "/" + user.getIdentity().getLogin() + "/image_" + imageSize 375 ); 376 } 377 378 /** 379 * Update user name, email, avatar... on chat server 380 * @param userIdentity The user to update 381 * @param changePassword Update the user password (slow) 382 * @throws UserPreferencesException If an error occurred while getting user infos 383 * @throws IOException If an error occurred while updating 384 * @throws InterruptedException If an error occurred while updating 385 */ 386 public void updateUserInfos(UserIdentity userIdentity, boolean changePassword) throws IOException, UserPreferencesException, InterruptedException 387 { 388 User user = _userManager.getUser(userIdentity); 389 if (user == null) 390 { 391 throw new IllegalStateException("Cannot update user in Rocket.Chat for unexisting user " + UserIdentity.userIdentityToString(userIdentity)); 392 } 393 394 Map<String, String> ametysUserInfo = getAmetysUserInfo(user, true, 64); 395 String userName = ametysUserInfo.get("userName"); 396 String userEmail = ametysUserInfo.get("userEmail"); 397 398 Map<String, String> data = new HashMap<>(); 399 data.put("email", userEmail); 400 data.put("name", userName); 401 if (changePassword) 402 { 403 data.put("password", _getUserPassword(userIdentity)); 404 } 405 406 Map<String, Object> updateInfos = _doPOST("v1/users.update", Map.of("userId", _getUserId(userIdentity), 407 "data", data)); 408 if (!_isOperationSuccessful(updateInfos)) 409 { 410 throw new IOException("Cannot update user " + UserIdentity.userIdentityToString(userIdentity) + " on chat server: " + _getError(updateInfos)); 411 } 412 413 if (changePassword) 414 { 415 // When changing password, it unlogs people and this takes time 416 Thread.sleep(1000); 417 } 418 419 _updateAvatar(userIdentity); 420 } 421 422 private void _updateAvatar(UserIdentity user) 423 { 424 ContextHelper.getRequest(_context).setAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED, true); 425 426 Source src = null; 427 try 428 { 429 User u = _userManager.getUser(user); 430 src = _sourceResolver.resolveURI(getAmetysUserInfo(u, false, 64).get("userAvatar")); 431 try (InputStream is = src.getInputStream()) 432 { 433 Map<String, Object> avatarInfo = _doMultipartPOST("v1/users.setAvatar", Map.of("username", _userIdentitytoUserName(user), 434 "image", is)); 435 if (!_isOperationSuccessful(avatarInfo)) 436 { 437 getLogger().warn("Fail to update avatar for user " + UserIdentity.userIdentityToString(user) + ": " + _getError(avatarInfo)); 438 } 439 } 440 } 441 catch (Exception e) 442 { 443 getLogger().warn("Fail to update avatar for user " + UserIdentity.userIdentityToString(user), e); 444 } 445 finally 446 { 447 _sourceResolver.release(src); 448 } 449 450 } 451 452 private String _userIdentitytoUserName(UserIdentity userIdentity) 453 { 454 return UserIdentity.userIdentityToString(userIdentity).replaceAll("[^a-zA-Z-.]", "_") 455 + "." + new String(__BASE64_ENCODER.encode(UserIdentity.userIdentityToString(userIdentity).getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); 456 } 457 458 private String _getUserPassword(UserIdentity userIdentity) throws UserPreferencesException 459 { 460 String cryptedPassword = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_PASSWORD); 461 if (!StringUtils.isBlank(cryptedPassword)) 462 { 463 try 464 { 465 return _cryptoHelper.decrypt(cryptedPassword); 466 } 467 catch (CryptoHelper.WrongKeyException e) 468 { 469 getLogger().warn("Password of user {} cannot be decrypted, and thus will be reset", UserIdentity.userIdentityToString(userIdentity), e); 470 } 471 } 472 473 return _generateAndStorePassword(userIdentity); 474 } 475 476 // renewIfNecessary Set to true, if there is a serious possibility that the token is no more valid 477 // as it cost, do not do it, if there was a valid request a few minutes before 478 private String _getUserAuthToken(UserIdentity userIdentity, boolean renewIfNecessary) throws UserPreferencesException, IOException, InterruptedException 479 { 480 String cryptedToken = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_TOKEN); 481 if (!StringUtils.isBlank(cryptedToken)) 482 { 483 try 484 { 485 String token = _cryptoHelper.decrypt(cryptedToken); 486 if (renewIfNecessary) 487 { 488 token = _renewTokenIfNecessary(userIdentity, token, _getUserId(userIdentity)); 489 } 490 return token; 491 } 492 catch (CryptoHelper.WrongKeyException e) 493 { 494 getLogger().warn("Token of user {} cannot be decrypted, and thus will be reset", UserIdentity.userIdentityToString(userIdentity), e); 495 } 496 } 497 return _generateAndStoreAuthToken(userIdentity, true); 498 } 499 500 501 private String _getUserId(UserIdentity userIdentity) throws UserPreferencesException 502 { 503 String userId = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_ID); 504 return userId; 505 } 506 507 private String _generateAndStorePassword(UserIdentity user) throws UserPreferencesException 508 { 509 Double random = Math.random(); 510 byte[] randoms = {random.byteValue()}; 511 String randomPassword = Sha2Crypt.sha256Crypt(randoms); 512 513 String cryptedPassword = _cryptoHelper.encrypt(randomPassword); 514 _userPreferencesManager.addUserPreference(user, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_PASSWORD, cryptedPassword); 515 516 return randomPassword; 517 } 518 519 520 @SuppressWarnings("unchecked") 521 private String _generateAndStoreAuthToken(UserIdentity user, boolean tryToChangePassword) throws IOException, UserPreferencesException, InterruptedException 522 { 523 Map<String, Object> loginInfo = _doPOST("v1/login", Map.of("user", _userIdentitytoUserName(user), 524 "password", _getUserPassword(user))); 525 if (_isOperationSuccessful(loginInfo)) 526 { 527 String authToken = (String) ((Map<String, Object>) loginInfo.get("data")).get("authToken"); 528 String cryptedAuthToken = _cryptoHelper.encrypt(authToken); 529 _userPreferencesManager.addUserPreference(user, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_TOKEN, cryptedAuthToken); 530 531 return authToken; 532 } 533 else if (tryToChangePassword) 534 { 535 this.updateUserInfos(user, true); 536 537 return _generateAndStoreAuthToken(user, false); 538 } 539 else 540 { 541 throw new IOException("Could not log user " + UserIdentity.userIdentityToString(user) + " into chat " + _getError(loginInfo)); 542 } 543 } 544 545 /** 546 * Read the JSON result to test for success 547 * @param result the JSON result of a rest call 548 * @return true if success 549 */ 550 protected boolean _isOperationSuccessful(Map<String, Object> result) 551 { 552 Boolean success = false; 553 if (result != null) 554 { 555 Object successObj = result.get("success"); 556 if (successObj instanceof Boolean) 557 { 558 success = (Boolean) successObj; 559 } 560 else if (successObj instanceof String) 561 { 562 success = "true".equalsIgnoreCase((String) successObj); 563 } 564 else 565 { 566 Object statusObj = result.get("status"); 567 if (statusObj instanceof String) 568 { 569 success = "success".equalsIgnoreCase((String) statusObj); 570 } 571 } 572 } 573 return success; 574 } 575 576 private String _renewTokenIfNecessary(UserIdentity userIdentity, String authToken, String userId) throws IOException, UserPreferencesException, InterruptedException 577 { 578 String newToken = authToken; 579 580 // Ensure token validity by getting status on chat server 581 String status = _computeStatus(userIdentity, authToken, userId, null); // No cache here on purpose, since we need to test the authToken 582 if (status == null) 583 { 584 // If we cannot get status, this is probably because the auth token has expired or the user was recreated. Try a new one 585 newToken = _generateAndStoreAuthToken(userIdentity, true); 586 587 status = _computeStatus(userIdentity, authToken, userId, "offline"); 588 } 589 _getStatusCache().put(userIdentity, status); 590 591 return newToken; 592 } 593 594 /** 595 * Login the current user 596 * @return The info about the user 597 * @throws UserPreferencesException If the user password stored in prefs has an issue 598 * @throws IOException If an error occurred 599 * @throws InterruptedException If an error occurred 600 */ 601 @Callable(rights = Callable.NO_CHECK_REQUIRED, allowAnonymous = true) 602 public Map<String, Object> login() throws IOException, UserPreferencesException, InterruptedException 603 { 604 // Get current user in Ametys 605 UserIdentity userIdentity = _currentUserProvider.getUser(); 606 if (!_isPartOfAnAuthorizedPopulation(userIdentity)) 607 { 608 return null; // Avoid 403 if the user is not connected (login screen...) 609 } 610 611 User user = _userManager.getUser(userIdentity); 612 if (user == null) 613 { 614 return null; 615 } 616 617 boolean notLoggedRecently = _getUpdatesCache().get(userIdentity) == null; 618 if (notLoggedRecently) // Do not call this too often to accelerate the loading of the page 619 { 620 // Ensure user exists on chat server 621 getUser(userIdentity, true); 622 } 623 624 // Get the login info of the user 625 String authToken = _getUserAuthToken(userIdentity, notLoggedRecently); 626 String userId = _getUserId(userIdentity); 627 628 return Map.of( 629 "authToken", authToken, 630 "userId", userId, 631 "userName", _userIdentitytoUserName(userIdentity), 632 "status", _computeStatusWithCache(userIdentity, authToken, userId), 633 "canCreate", !_isPartOfALimitedPopulation(userIdentity), 634 "url", Config.getInstance().getValue("rocket.chat.rocket.url"), 635 "video", StringUtils.isNotBlank(Config.getInstance().getValue("rocket.chat.video.url", false, "")) 636 ); 637 } 638 639 /** 640 * Get the current cache status 641 * @return The association login#populationId <-> status (online, offline...) 642 * @throws InterruptedException If an error occurred 643 * @throws IOException If an error occurred 644 * @throws UserPreferencesException If an error occurred 645 */ 646 @Callable(rights = Callable.NO_CHECK_REQUIRED, allowAnonymous = true) 647 public Map<String, String> getStatusCache() throws UserPreferencesException, IOException, InterruptedException 648 { 649 // Get current user in Ametys 650 UserIdentity userIdentity = _currentUserProvider.getUser(); 651 if (!_isPartOfAnAuthorizedPopulation(userIdentity)) 652 { 653 return null; // Avoid 403 if the user is not connected (login screen...) 654 } 655 656 Request request = ContextHelper.getRequest(_context); 657 658 String sitename = WebHelper.getSiteName(request); 659 Set<String> populations = _populationContextHelper.getUserPopulationsOnContexts(Set.of("/sites/" + sitename, "/sites-fo/" + sitename), false, true); 660 661 return _getStatusCache().asMap().entrySet().stream() 662 .filter(e -> populations.contains(e.getKey().getPopulationId())) 663 .map(e -> Pair.of(UserIdentity.userIdentityToString(e.getKey()), e.getValue())) 664 .collect(Collectors.toMap(Pair::getKey, Pair::getValue)); 665 } 666 667 /** 668 * Set the current user new status 669 * @param newStatus The new status between online, offline, busy or away 670 * @throws InterruptedException If an error occurred 671 * @throws IOException If an error occurred 672 * @throws UserPreferencesException If an error occurred 673 */ 674 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 675 public void setStatus(String newStatus) throws UserPreferencesException, IOException, InterruptedException 676 { 677 // Get current user in Ametys 678 UserIdentity userIdentity = _currentUserProvider.getUser(); 679 if (!_isPartOfAnAuthorizedPopulation(userIdentity)) 680 { 681 return; 682 } 683 684 // Get the login info of the user 685 String authToken = _getUserAuthToken(userIdentity, false); 686 String userId = _getUserId(userIdentity); 687 688 Map<String, Object> response = _doPOST("v1/users.setStatus", Map.of("message", "-", "status", newStatus), authToken, userId); 689 if (!_isOperationSuccessful(response)) 690 { 691 getLogger().error("Cannot set status of " + userIdentity + " because: " + response.get("error")); 692 } 693 else 694 { 695 _getStatusCache().put(userIdentity, newStatus); 696 } 697 } 698 699 /** 700 * Get the last messages of the current user 701 * @return The messages 702 * @throws IOException something went wrong 703 * @throws UserPreferencesException something went wrong 704 * @throws InterruptedException something went wrong 705 */ 706 @SuppressWarnings("unchecked") 707 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 708 public Collection<Map<String, Object>> getLastMessages() throws IOException, UserPreferencesException, InterruptedException 709 { 710 Map<String, Map<String, Object>> responses = new LinkedHashMap<>(); 711 712 // Get current user in Ametys 713 UserIdentity user = _currentUserProvider.getUser(); 714 if (!_isPartOfAnAuthorizedPopulation(user)) 715 { 716 return null; 717 } 718 719 // Get the login info of the user 720 String authToken = _getUserAuthToken(user, false); 721 String userId = _getUserId(user); 722 723 Map<String, Object> statusInfo = _doGet("v1/im.list", Map.of("sort", "{ \"_updatedAt\": -1 }"), authToken, userId); 724 if (_isOperationSuccessful(statusInfo)) 725 { 726 List<Map<String, Object>> ims = (List<Map<String, Object>>) statusInfo.get("ims"); 727 for (Map<String, Object> im : ims) 728 { 729 List<User> users = _getUsers((List<String>) im.get("_USERNAMES"), UserIdentity.userIdentityToString(user).toString()); 730 731 if (users.size() > 0) 732 { 733 Map<String, Object> response = new HashMap<>(); 734 response.putAll(Map.of( 735 "id", im.get("_id"), 736 "authors", users.stream().filter(Objects::nonNull).map(u -> Map.of( 737 "identity", UserIdentity.userIdentityToString(u.getIdentity()), 738 "fullname", u.getFullName(), 739 "avatar", getAmetysUserInfo(u, true, 76).get("userAvatar"), 740 "status", users.size() == 1 ? _computeStatusWithCache(u.getIdentity(), authToken, userId) : "")).toList(), 741 "lastDate", im.get("_updatedAt"), 742 "lastMessage", _getLastMessage((Map<String, Object>) im.get("lastMessage")) 743 )); 744 745 responses.put((String) im.get("_id"), response); 746 } 747 } 748 749 Map<String, Object> unreadInfo = _doGet("v1/subscriptions.get", Map.of(), authToken, userId); 750 if (unreadInfo != null) 751 { 752 List<Map<String, Object>> updates = (List<Map<String, Object>>) unreadInfo.get("update"); 753 for (Map<String, Object> update : updates) 754 { 755 String id = (String) update.get("rid"); 756 Map<String, Object> response = responses.get(id); 757 if (response != null) 758 { 759 response.put("unread", (int) update.get("unread")); 760 response.put("mentions", ((int) update.get("userMentions")) > 0 761 || ((int) update.get("groupMentions")) > 0); 762 } 763 } 764 } 765 return responses.values(); 766 } 767 else 768 { 769 getLogger().error("Cannot get last messages of " + user + " because: " + statusInfo.get("error")); 770 return null; 771 } 772 773 } 774 775 private String _computeStatusWithCache(UserIdentity user, String authToken, String userId) 776 { 777 return _getStatusCache().get(user, __ -> _computeStatus(user, authToken, userId, "offline")); 778 } 779 780 private String _computeStatus(UserIdentity user, String authToken, String userId, String defaultValue) 781 { 782 try 783 { 784 Map<String, Object> statusInfo = _doGet("v1/users.getStatus", Map.of("username", _userIdentitytoUserName(user)), authToken, userId); 785 if (!_isOperationSuccessful(statusInfo)) 786 { 787 getLogger().error("Cannot get status of user " + UserIdentity.userIdentityToString(user) + " because Rocket.Chat returned: " + statusInfo.get("error")); 788 return defaultValue; 789 } 790 else 791 { 792 return (String) statusInfo.get("status"); 793 } 794 } 795 catch (IOException e) 796 { 797 throw new RuntimeException(e); 798 } 799 } 800 801 private String _usernameToUserIdentity(String username) 802 { 803 return Optional.ofNullable(username) 804 .filter(u -> u.contains(".")) 805 .map(u -> StringUtils.substringAfterLast(u, ".")) 806 .map(b64 -> new String(__BASE64_DECODER.decode(b64.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8)) 807 .orElse(""); 808 } 809 810 private List<User> _getUsers(List<String> usernames, String avoid) 811 { 812 return Optional.ofNullable(usernames) 813 .map(us -> us.stream() 814 .map(u -> _usernameToUserIdentity(u)) 815 .filter(u -> StringUtils.isNotBlank(u) && !Strings.CS.equals(u, avoid)) 816 .map(UserIdentity::stringToUserIdentity) 817 .map(ud -> _userManager.getUser(ud)) 818 .toList()) 819 .orElse(List.of()); 820 } 821 822 @SuppressWarnings("unchecked") 823 private Map<String, Object> _getLastMessage(Map<String, Object> lastMessage) 824 { 825 if (lastMessage == null) 826 { 827 return Map.of(); 828 } 829 830 return Map.of( 831 "author", _usernameToUserIdentity(((Map<String, String>) lastMessage.get("u")).get("username")), 832 "message", _getMessageText(lastMessage) 833 ); 834 } 835 836 private String _getMessageText(Map<String, Object> message) 837 { 838 String simpleText = (String) message.get("msg"); 839 840 @SuppressWarnings("unchecked") 841 List<Map<String, Object>> attachments = (List<Map<String, Object>>) message.get("attachments"); 842 843 if (StringUtils.isNotBlank(simpleText)) 844 { 845 return simpleText.replaceAll("\\[([^\\]]+)\\]\\([^)]+\\)", "$1") // Link with description text: [Ametys](https://www.ametys.org) => Ametys 846 .replaceAll("\\[[^\\]]*\\]\\(([^)]+)\\)", "$1") // Link with NO description text: [](https://www.ametys.org) => https://www.ametys.org 847 .replaceAll("\\*([^\\s][^\\n]*)\\*", "$1") // Bold text: *test* => test 848 .replaceAll("_([^\\s][^\\n]*)_", "$1") // Underline text: _test_ => test 849 .replaceAll("~([^\s][^\n]*)~", "$1") // Stroke text: ~test~ => test 850 .replaceAll("```", "") // Multiline code: `test` => test (same as rocket.chat) do 851 .replaceAll("`([^\n]*)`", "$1"); // Inline code: `test` => test 852 853 } 854 else if (attachments != null && attachments.size() > 0) 855 { 856 return StringUtils.defaultIfBlank((String) attachments.get(0).get("description"), (String) attachments.get(0).get("title")); 857 } 858 else 859 { 860 return ""; 861 } 862 } 863 864 /** 865 * Creates a new chat with the current user and the given users 866 * @param users The parteners 867 * @return The chat id 868 * @throws InterruptedException If an error occurred 869 * @throws IOException If an error occurred 870 * @throws UserPreferencesException If an error occurred 871 */ 872 @SuppressWarnings("unchecked") 873 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 874 public String createChat(List<String> users) throws UserPreferencesException, IOException, InterruptedException 875 { 876 // Get current user in Ametys 877 UserIdentity user = _currentUserProvider.getUser(); 878 if (_isPartOfALimitedPopulation(user)) 879 { 880 getLogger().error("User {} is not authorized to create a new chat", UserIdentity.userIdentityToString(user)); 881 return null; 882 } 883 884 // Get the login info of the user 885 String authToken = _getUserAuthToken(user, false); 886 String userId = _getUserId(user); 887 888 // Creates users if not existing 889 List<String> rcUsers = users.stream() 890 .map(UserIdentity::stringToUserIdentity) 891 .map(LambdaUtils.wrap(ud -> getUser(ud, false))) 892 .map(m -> (String) m.get("username")) 893 .toList(); 894 895 List<String> finalRcUsers = new ArrayList<>(rcUsers); 896 // finalRcUsers.addFirst(userIdentitytoUserName(user)); 897 898 Map<String, Object> statusInfo = _doPOST("v1/im.create", Map.of("usernames", StringUtils.join(finalRcUsers, ", ")), authToken, userId); 899 if (!_isOperationSuccessful(statusInfo)) 900 { 901 getLogger().error("Cannot create the new chat: " + statusInfo.get("message")); 902 return null; 903 } 904 905 return (String) ((Map<String, Object>) statusInfo.get("room")).get("rid"); 906 } 907 908 /** 909 * List all the users with messages in the given time window 910 * @param since The date since the messages should be considered 911 * @return The list of users 912 * @throws IOException If an error occurred 913 */ 914 public Set<UserIdentity> getUsersWithRecentMessages(ZonedDateTime since) throws IOException 915 { 916 Set<UserIdentity> users = new HashSet<>(); 917 918 int offset = 0; 919 final int count = 50; 920 int total = 1; 921 922 while (total > offset) 923 { 924 Map<String, Object> dmEveryone = _doGet("v1/im.list.everyone", Map.of( 925 "sort", "{ \"_updatedAt\": -1 }", 926 "offset", Integer.toString(offset), 927 "count", Integer.toString(count) 928 )); 929 930 if (!_isOperationSuccessful(dmEveryone)) 931 { 932 throw new IOException("Cannot get the DM: " + dmEveryone.get("message")); 933 } 934 935 total = (int) dmEveryone.get("total"); 936 937 @SuppressWarnings("unchecked") 938 List<Map<String, Object>> ims = (List<Map<String, Object>>) dmEveryone.get("ims"); 939 for (Map<String, Object> im : ims) 940 { 941 String updatedAtString = (String) im.get("_updatedAt"); 942 ZonedDateTime updatedAt = DateUtils.parseZonedDateTime(updatedAtString); 943 944 if (updatedAt.compareTo(since) < 0) 945 { 946 offset = total; 947 break; 948 } 949 950 @SuppressWarnings("unchecked") 951 Map<String, Object> lastMessage = (Map<String, Object>) im.get("lastMessage"); 952 if (lastMessage != null) 953 { 954 ZonedDateTime ts = DateUtils.parseZonedDateTime((String) lastMessage.get("ts")); 955 956 if (ts.compareTo(since) >= 0) 957 { 958 @SuppressWarnings("unchecked") 959 List<String> usernames = (List<String>) im.get("usernames"); 960 for (String username : usernames) 961 { 962 UserIdentity user = UserIdentity.stringToUserIdentity(_usernameToUserIdentity(username)); 963 users.add(user); 964 } 965 } 966 } 967 } 968 969 offset += count; 970 } 971 972 return users; 973 } 974 975 /** 976 * List the unread messages of a user in the given time window 977 * @param user The user to consider 978 * @param since The date to consider 979 * @return The ids of the room containing unread messages 980 * @throws IOException If an error occurred 981 * @throws UserPreferencesException If an error occurred 982 * @throws InterruptedException If an error occurred 983 */ 984 public Set<RoomInfo> getThreadsWithUnreadMessages(UserIdentity user, ZonedDateTime since) throws IOException, UserPreferencesException, InterruptedException 985 { 986 Set<RoomInfo> roomsInfos = new HashSet<>(); 987 988 // Ensure user exists on chat server 989 getUser(user, false); 990 991 // Get the login info of the user 992 String authToken = _getUserAuthToken(user, true); 993 String userId = _getUserId(user); 994 995 Map<String, Object> messages = _doGet("v1/subscriptions.get", Map.of( 996 "updatedSince", DateUtils.zonedDateTimeToString(since) 997 ), authToken, userId); 998 999 if (!_isOperationSuccessful(messages)) 1000 { 1001 throw new IOException("Cannot get the messages of " + UserIdentity.userIdentityToString(user) + ": " + messages.get("message")); 1002 } 1003 1004 @SuppressWarnings("unchecked") 1005 List<Map<String, Object>> updates = (List<Map<String, Object>>) messages.get("update"); 1006 for (Map<String, Object> update : updates) 1007 { 1008 int unread = (int) update.get("unread"); 1009 if (unread > 0) 1010 { 1011 String roomId = (String) update.get("rid"); 1012 String roomLabel = (String) update.get("fname"); 1013 1014 roomsInfos.add(new RoomInfo(roomId, roomLabel, unread)); 1015 } 1016 } 1017 1018 return roomsInfos; 1019 } 1020 1021 /** 1022 * Get the n last messages of the user in the room 1023 * @param user The user to consider 1024 * @param roomId The room id to consider 1025 * @param count The number of messages to retrieve 1026 * @param since Since the max date 1027 * @return The message 1028 * @throws IOException If an error occurred 1029 * @throws UserPreferencesException If an error occurred 1030 * @throws InterruptedException If an error occurred 1031 */ 1032 public List<Message> getLastMessages(UserIdentity user, String roomId, int count, ZonedDateTime since) throws IOException, UserPreferencesException, InterruptedException 1033 { 1034 List<Message> messagesReceived = new ArrayList<>(); 1035 1036 // Get the login info of the user 1037 String authToken = _getUserAuthToken(user, false); 1038 String userId = _getUserId(user); 1039 1040 Map<String, Object> messagesInfo = _doGet("v1/im.messages", Map.of( 1041 "sort", "{ \"_updatedAt\": -1 }", 1042 "roomId", roomId, 1043 "count", Integer.toString(count) 1044 ), authToken, userId); 1045 1046 if (!_isOperationSuccessful(messagesInfo)) 1047 { 1048 if ("[invalid-channel]".equals(messagesInfo.get("error"))) 1049 { 1050 // can happen when destroying/recreating users 1051 return List.of(); 1052 } 1053 throw new IOException("Cannot get the messages of " + UserIdentity.userIdentityToString(user) + ": " + messagesInfo.get("error")); 1054 } 1055 1056 @SuppressWarnings("unchecked") 1057 List<Map<String, Object>> messages = (List<Map<String, Object>>) messagesInfo.get("messages"); 1058 for (Map<String, Object> message : messages) 1059 { 1060 ZonedDateTime ts = DateUtils.parseZonedDateTime((String) message.get("ts")); 1061 1062 if (ts.compareTo(since) >= 0) 1063 { 1064 String text = _getMessageText(message); 1065 @SuppressWarnings("unchecked") 1066 UserIdentity author = UserIdentity.stringToUserIdentity(_usernameToUserIdentity((String) ((Map<String, Object>) message.get("u")).get("username"))); 1067 ZonedDateTime date = ts; 1068 1069 messagesReceived.add(new Message(author, date, text)); 1070 } 1071 } 1072 1073 messagesReceived.sort((d1, d2) -> d1.date().compareTo(d2.date())); 1074 1075 return messagesReceived; 1076 } 1077 1078 private boolean _isPartOfAnAuthorizedPopulation(UserIdentity userIdentity) 1079 { 1080 if (userIdentity == null) 1081 { 1082 return false; 1083 } 1084 1085 String populationId = userIdentity.getPopulationId(); 1086 if (StringUtils.isBlank(populationId)) 1087 { 1088 return false; 1089 } 1090 1091 for (String forbiddenPopulationId : StringUtils.split(Config.getInstance().getValue("rocket.chat.rocket.population.forbidden"), ",")) 1092 { 1093 if (forbiddenPopulationId.trim().equals(populationId)) 1094 { 1095 return false; 1096 } 1097 } 1098 1099 return true; 1100 } 1101 1102 private boolean _isPartOfALimitedPopulation(UserIdentity userIdentity) 1103 { 1104 if (userIdentity == null) 1105 { 1106 return true; 1107 } 1108 1109 String populationId = userIdentity.getPopulationId(); 1110 if (StringUtils.isBlank(populationId)) 1111 { 1112 return true; 1113 } 1114 1115 for (String forbiddenPopulationId : StringUtils.split(Config.getInstance().getValue("rocket.chat.rocket.population.limited"), ",")) 1116 { 1117 if (forbiddenPopulationId.trim().equals(populationId)) 1118 { 1119 return true; 1120 } 1121 } 1122 1123 return false; 1124 } 1125 1126 /** 1127 * A Rocket.Chat message 1128 * @param author The author of the message 1129 * @param date The date of the message 1130 * @param text The text of the message 1131 */ 1132 public record Message(UserIdentity author, ZonedDateTime date, String text) { /* empty */ } 1133 /** 1134 * A Rocket.Chat room info for a user 1135 * @param roomId The room id 1136 * @param roomLabel The room name 1137 * @param unread The number of unread items for the user 1138 */ 1139 public record RoomInfo(String roomId, String roomLabel, int unread) { /* empty */ } 1140}