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